Mock Exam · Set 3
Your third full sitting, and the hardest of the three. Eighteen performance-based tasks in one unbroken 120-minute block, weighted exactly to the CNPE blueprint, on a throwaway cluster with only the permitted documentation open. Every task on this paper is new: nothing here repeats Set 1, Set 2 or any of the five practice banks, and the topics deliberately sit one step beyond the obvious ones — an Argo CD AppProject rather than an Application, Flux image automation rather than a plain Kustomization, a Crossplane composition function pipeline rather than a first Composition, a Gatekeeper policy that has to look at the rest of the cluster to decide. Each task gives you a realistic situation, a short numbered brief, an objective done when check, and a point value; solutions are folded away so you can sit the paper honestly and mark it after. Total the sheet, compare against the 64% bar, and let the misses choose your next fortnight.
You have played two practice matches at home. This one is away. Same rules, same clock, same referee — but the pitch is unfamiliar and the other team plays a bit differently, so the things you had got comfortable with do not turn up in quite the shape you expected. That is the whole point of a third match: not to prove you can do the drills you already practised, but to find out what happens when the question arrives sideways. Play the full ninety minutes, then look at the scoresheet and see which part of your game only worked at home.
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 sideline.
The fastest way to waste this paper is to sit it comfortably: a pause here, a search there, a peek at a solution when a manifest will not validate. That produces a score 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. Set 3 leans on a wider tool spread than the earlier papers, so install ahead of time: Argo CD, Argo Workflows, Flux including the image-reflector and image-automation controllers, Flagger with its load tester and a mesh or ingress provider, Crossplane with the go-templating and auto-ready functions, the kube-prometheus-stack, Loki with logcli, an OpenTelemetry Collector, cert-manager, Gatekeeper and Kyverno, plus Go and kubebuilder on the desktop. If a component is missing when you reach its task, take the zero and move on — never stop the clock to install things. Two tasks (X8 Backstage, X10 kubebuilder) can be worked on disk without a cluster if you would rather not run a Backstage instance.
Two — one timer, started once. 120 minutes, no pausing for coffee, a broken cluster or a stubborn CRD. If you lose eight minutes to your own laptop, that is data — the real exam will also eat time in ways you did not plan for. 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 desktop — man pages and whatever ships under /usr/share. That is the entire list. The documentation for Argo CD, Flux, Tekton, Crossplane, Backstage, Prometheus, OpenTelemetry, Kyverno, Gatekeeper, Istio and Helm is 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. When you stall, spend the 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.
That list changes how you must prepare. For every tool whose own documentation is barred, the resource shape has to be in your head or reachable from what to know cold — you cannot look up an AppProject, an ImagePolicy, a CronWorkflow or a ConstraintTemplate 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. Task counts, the 120-minute duration, the 64% pass mark, domain weights, the permitted-resource list and the Kubernetes version on the desktop have all changed before and will change again — and so have the API versions of half the CRDs on this page. Everything here is a study aid written at a point in time; the official CNCF certification pages and Linux Foundation training site are the only authority. Confirm before you book.
Five — read the whole paper first, then choose your order. Five minutes spent reading all eighteen tasks is the highest-return five minutes of the sitting. You will spot the three you can finish in four minutes each 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 — and on this paper the single most valuable task (X17, eight points) is second from last, with the two-task Security block at the bottom carrying fifteen points between them.
Your time budget
☺ Like you’re 10: Give every question a small share of the two hours. When a question goes over its share, put a sticky note on it and move on — you can always come back.
Eighteen tasks in 120 minutes averages 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.
| Block | Tasks | Budget | Running total | Note |
|---|---|---|---|---|
| Read the whole paper | — | 5 min | 5 | Mark each task cheap / medium / expensive before you touch a terminal. |
| GitOps & Continuous Delivery | X1–X5 | 5 + 6 + 7 + 6 + 6 = 30 min | 35 | Biggest domain. X3 is the expensive one — three objects and a commit. |
| Platform APIs & Self-Service | X6–X10 | 6 + 7 + 6 + 5 + 6 = 30 min | 65 | X9 is the cheapest task in this block — four points in five minutes. |
| Observability & Operations | X11–X13 | 7 + 7 + 6 = 20 min | 85 | X13 is triage; it either resolves fast or swallows your slack. |
| Platform Architecture & Infrastructure | X14–X16 | 5 + 6 + 5 = 16 min | 101 | Two triage tasks and one build. Good points-per-minute if you keep moving. |
| Security & Policy Enforcement | X17–X18 | 6 + 6 = 12 min | 113 | Two tasks worth 15 points between them — the best rate on the paper. |
| Verify & sweep flagged tasks | — | 7 min | 120 | Re-run every done-when. Finish the nearly-done before starting anything new. |
The flag-and-move rule: when a task passes roughly six minutes without a working done-when check, stop. Write one line about where you stalled, leave whatever you applied in place — partial credit is real — and go to the next task. The 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, unchanged since Set 1: you are not paid to finish tasks, you are paid to bank points.
Points, not tasks, are the unit of this exam. At a 64% bar you can fail four tasks outright and still pass comfortably — but only if you actually attempted the cheap ones. Candidates rarely fail because they knew too little; they fail because twenty-two minutes went into one Crossplane pipeline and the two security tasks worth fifteen points at the bottom of the paper were never opened.
GitOps & Continuous Delivery — X1 to X5 (25 points)
☺ Like you’re 10: These five are about the robot that reads instructions from a folder — but this time you are fencing off what it is allowed to touch, teaching it to write new version numbers back into the folder itself, and putting a human gate in front of the risky bit.
The joint-largest domain, and the one where speed pays most. Background reading lives in GitOps workflows, CI/CD & progressive delivery and release engineering — but read those after the sitting, not during it.
X1 · Fence a tenant into an Argo CD AppProject
A tenant team’s payments Application quietly synced a ClusterRole that granted them cluster-wide secret read, and last Friday somebody pointed the same Application at a personal fork. Both were possible because everything on this cluster lives in the default project, which permits any repo, any destination and any resource kind.
Your task:
- Create an
AppProjectnamedtenant-paymentsthat permits exactly one source repo, exactly one destination namespace on the in-cluster server, and no cluster-scoped resources at all. - Move the
paymentsApplication into that project without changing anything else about it. - Prove the fence holds: a sync that includes a
ClusterRolemust be refused.
Done when: kubectl -n argocd get app payments -o jsonpath='{.spec.project}' prints tenant-payments, and after committing a ClusterRole into the app’s path, argocd app sync payments fails with a message containing is not permitted in project (also 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: tenant-payments
namespace: argocd
spec:
description: Payments tenant — one repo, one namespace, no cluster-scoped resources
sourceRepos:
- https://github.com/acme/platform-config.git
destinations:
- server: https://kubernetes.default.svc
namespace: payments
clusterResourceWhitelist: [] # empty list = no cluster-scoped kinds at all
namespaceResourceBlacklist: # platform owns these, tenants may not set them
- group: ""
kind: ResourceQuota
- group: ""
kind: LimitRange
orphanedResources:
warn: true # flag live resources the tenant never declared
syncWindows:
- kind: deny
schedule: "0 18 * * 5" # Friday 18:00 …
duration: 62h # … until Monday 08:00
timeZone: "Europe/London"
applications: ["*"]
manualSync: false # even a human cannot force a sync in the window
roles:
- name: read-only
description: Tenant engineers may look, not sync
policies:
- p, proj:tenant-payments:read-only, applications, get, tenant-payments/*, allowkubectl apply -f appproject.yaml
argocd proj get tenant-payments
argocd proj allow-cluster-resource tenant-payments '' Namespace # if you must permit one
kubectl -n argocd patch app payments --type=merge -p '{"spec":{"project":"tenant-payments"}}'
kubectl -n argocd get app payments -o jsonpath='{.spec.project}{"\n"}'
# now commit a ClusterRole into apps/payments/ and sync:
argocd app sync payments
# FATA[0001] rpc error: ... Resource rbac.authorization.k8s.io:ClusterRole is not
# permitted in project tenant-payments
kubectl -n argocd get app payments -o jsonpath='{.status.conditions[*].message}{"\n"}'Why: the AppProject is Argo CD’s tenancy boundary and it answers three separate questions — where may manifests come from (sourceRepos), where may they land (destinations), and what may they contain (the resource whitelists and blacklists). An empty clusterResourceWhitelist is not “no opinion,” it is “nothing,” which is exactly the fence you want around a tenant; the blacklists then remove individual namespaced kinds the platform reserves for itself. Two extras earn their place on a real platform: orphanedResources.warn surfaces live objects nobody declared, and a deny sync window enforces a change freeze in the controller rather than in a wiki page. Note that project restrictions are evaluated at sync time against rendered manifests, so a violating resource shows up as a failed sync rather than a rejected commit — pair this with a repo-side check if you want the feedback earlier. More on tenancy in governance & compliance.
X2 · Bootstrap a whole cluster with App-of-Apps
Standing up a replacement cluster currently means an afternoon of running kubectl apply against fourteen hand-written Application manifests in the right order, from a folder on one engineer’s laptop. Two of the fourteen were missed last time and nobody noticed for a week.
Your task:
- Create a single root Application that points at the
bootstrap/proddirectory of the config repo, where the fourteen childApplicationmanifests live, recursing into subdirectories. - Give it automated sync with prune and self-heal, target the
argocdnamespace, and add the Argo CD resources finalizer so deleting the root removes everything it created. - Show the children appear, then prove the cascade by deleting the root on a scratch cluster.
Done when: kubectl -n argocd get applications lists root plus every child application, kubectl -n argocd get app root -o jsonpath='{.status.sync.status}' prints Synced, and after kubectl -n argocd delete app root the child Applications are gone too (kubectl -n argocd get applications returns nothing). (5 points)
Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io # cascade: delete root, delete children
spec:
project: default
source:
repoURL: https://github.com/acme/platform-config.git
targetRevision: main
path: bootstrap/prod
directory:
recurse: true
include: '{*.yaml,*/*.yaml}'
destination:
server: https://kubernetes.default.svc
namespace: argocd # the children are Application objects — they live here
syncPolicy:
automated:
prune: true # an Application deleted from Git is deleted for real
selfHeal: true
syncOptions:
- CreateNamespace=true# bootstrap/prod/30-monitoring.yaml — one of the fourteen children, ordered by wave
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "30"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: platform
source:
repoURL: https://github.com/acme/platform-config.git
targetRevision: main
path: infrastructure/monitoring
destination:
server: https://kubernetes.default.svc
namespace: monitoring
syncPolicy:
automated: { prune: true, selfHeal: true }
syncOptions: [ CreateNamespace=true ]kubectl apply -f root-app.yaml kubectl -n argocd get applications # root + 14 children argocd app get root argocd app resources root # the children, as resources of root # cascade check — on a scratch cluster only kubectl -n argocd delete app root kubectl -n argocd get applications # empty
Why: App-of-Apps works because an Application is an ordinary Kubernetes object, so a root Application can manage a directory of them exactly as it would manage Deployments. That single fact gives you the whole property you wanted: bootstrapping a cluster becomes “apply one manifest,” drift in the set of applications is corrected by self-heal, and removing a component from the platform is a file deletion plus prune. The finalizer is the part candidates forget — without resources-finalizer.argocd.argoproj.io, deleting the root leaves fourteen orphaned Applications still reconciling. Use sync waves on the children when order matters (CRDs and cert-manager before anything that depends on them). Reach for an ApplicationSet instead when the set is templated — one app per tenant folder or per cluster; App-of-Apps is the right shape for a fixed, hand-curated bootstrap list.
X3 · Let Flux write image tags back to Git
Every patch release of the api service needs a human to open a pull request that changes one digit in one YAML file. It takes four minutes and it is forgotten on Fridays, so production runs a fortnight behind the registry. The team wants patch versions to flow automatically — but the commit must still land in Git, because Git is the source of truth.
Your task:
- Create an
ImageRepositorythat scansghcr.io/acme/apievery five minutes, and anImagePolicythat selects the newest tag within>=1.4.0 <2.0.0. - Mark the image field in the Deployment manifest with the setter comment so the automation knows exactly what to rewrite.
- Create an
ImageUpdateAutomationthat commits the change back tomainwith a clear author and message, and prove a commit lands.
Done when: flux get image repository api shows the tag count and last scan, flux get image policy api shows the selected latestImage, and after flux reconcile image update api a git pull && git log --oneline -1 in the config repo shows an automation commit whose diff bumps the image tag to that same value. (6 points)
Show the worked solution
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
name: api
namespace: flux-system
spec:
image: ghcr.io/acme/api
interval: 5m
secretRef:
name: ghcr-auth # docker-registry secret; omit for a public repo
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
name: api
namespace: flux-system
spec:
imageRepositoryRef:
name: api
policy:
semver:
range: ">=1.4.0 <2.0.0" # patch and minor flow; a major never does
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata:
name: api
namespace: flux-system
spec:
interval: 5m
sourceRef:
kind: GitRepository
name: flux-system # must have WRITE credentials, not a read-only key
git:
checkout:
ref:
branch: main
commit:
author:
name: fluxcdbot
email: fluxcdbot@users.noreply.github.com
messageTemplate: |
chore(images): automated update
{{ range .Updated.Images }}- {{ . }}
{{ end }}
push:
branch: main # set a different branch here to get a PR flow instead
update:
path: ./apps/api
strategy: Setters# apps/api/deployment.yaml — the marker is the whole contract
spec:
containers:
- name: api
image: ghcr.io/acme/api:1.4.2 # {"$imagepolicy": "flux-system:api"}kubectl apply -f image-automation.yaml
flux get image repository api
flux get image policy api # LATEST IMAGE ghcr.io/acme/api:1.4.9
flux reconcile image repository api # force a scan
flux reconcile image update api # force a commit
git pull && git log --oneline -1
git show --stat HEAD
kubectl -n apps get deploy api -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
# these APIs have moved between beta versions — check the cluster in front of you
kubectl api-resources | grep image.toolkitWhy: three objects with three jobs, and the exam rewards knowing which is which. The ImageRepository only scans a registry and caches the tag list. The ImagePolicy only chooses one tag from that list — by semver range, numerical order or a regex filter — and publishes it as status.latestImage. The ImageUpdateAutomation is the only one that writes, and it writes to Git, not to the cluster: the cluster still changes because the ordinary Kustomization reconciles the new commit, so the GitOps audit trail stays intact. The setter marker is the entire targeting mechanism — no marker, no change, silently — and its value is <namespace>:<policy-name>. Two failure modes to recognise: a read-only deploy key makes the automation reconcile forever with a push error, and a policy range that includes a major version will happily ship you a breaking change at 03:00. If you want review in the loop, push to a separate branch and open the PR from there. See Flux and release engineering.
X4 · Make a nightly Argo Workflow safe to run unattended
The nightly reconciliation Workflow is submitted by a CronJob that runs argo submit. When it runs long, a second copy starts on top of the first and they fight. Completed pods are never cleaned up — there are 2,300 in the namespace. And when the final publish step fails, nobody finds out until morning.
Your task:
- Move the workflow into a
WorkflowTemplateand schedule it with aCronWorkflowat 02:00 that refuses to overlap itself. - Add a retry strategy with exponential backoff to the flaky step, and an
onExithandler that reports the final status whatever happens. - Add pod garbage collection and a TTL so completed workflows and their pods clean themselves up.
Done when: kubectl -n argo get cronworkflow nightly-reconcile shows the schedule, argo submit --from cronwf/nightly-reconcile -n argo --watch reaches Succeeded, argo get @latest -n argo shows the notify exit-handler node having run, and kubectl -n argo get pods shows no leftover pods from that run. (5 points)
Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: nightly-reconcile
namespace: argo
spec:
entrypoint: main
onExit: notify # runs on success, failure and error
podGC:
strategy: OnWorkflowSuccess # keep the pods of a failed run for debugging
ttlStrategy:
secondsAfterSuccess: 3600
secondsAfterFailure: 86400 # failures stick around a day
templates:
- name: main
dag:
tasks:
- name: drift-scan
template: run
arguments:
parameters: [{ name: cmd, value: "drift-scan" }]
- name: publish
template: run
dependencies: [drift-scan]
arguments:
parameters: [{ name: cmd, value: "publish" }]
- name: run
inputs:
parameters: [{ name: cmd }]
retryStrategy:
limit: "3"
retryPolicy: "OnTransientError"
backoff:
duration: "10s"
factor: "2"
maxDuration: "2m"
container:
image: alpine:3.20
command: [sh, -c]
args: ["echo running {{inputs.parameters.cmd}}; sleep 5"]
- name: notify
container:
image: alpine:3.20
command: [sh, -c]
args:
- "echo workflow {{workflow.name}} finished: {{workflow.status}} ({{workflow.duration}}s)"apiVersion: argoproj.io/v1alpha1
kind: CronWorkflow
metadata:
name: nightly-reconcile
namespace: argo
spec:
schedule: "0 2 * * *" # newer Argo Workflows: schedules: ["0 2 * * *"]
timezone: "Europe/London"
concurrencyPolicy: Forbid # never start a second copy on top of the first
startingDeadlineSeconds: 300 # a missed slot is skipped, not run at 09:00
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
workflowSpec:
workflowTemplateRef:
name: nightly-reconcilekubectl apply -f nightly.yaml argo cron list -n argo argo cron get nightly-reconcile -n argo # test it now instead of waiting for 02:00 argo submit --from cronwf/nightly-reconcile -n argo --watch argo get @latest -n argo # the notify node appears at the end of the tree argo logs @latest -n argo kubectl -n argo get pods # cleaned up after a successful run argo cron suspend nightly-reconcile -n argo # and resume, during a freeze
Why: four separate mechanisms, one per symptom. concurrencyPolicy: Forbid is what stops the overlap — Allow is the default and is how you get two copies fighting, while Replace kills the running one. retryStrategy lives on the template, so it retries the step rather than the workflow, and retryPolicy decides what counts as retryable: OnTransientError retries infrastructure failures but not your script exiting 1, which is usually what you want. onExit is the only place a notification belongs, because it runs whatever the outcome — a final DAG task does not run when an earlier task failed. Cleanup splits in two: podGC deletes pods while the Workflow object survives for inspection, and ttlStrategy deletes the Workflow itself — you need both, and keeping failures longer than successes costs nothing. Note the schedule field became a schedules list in newer releases; check with kubectl explain cronworkflow.spec rather than guessing. See Argo Workflows.
X5 · Put a human gate and a business metric on a Flagger canary
After a release that passed every technical check and still lost money, compliance requires a named human to confirm before any production traffic moves to a new version of checkout. The team also wants the canary judged on their own SLI — the checkout 5xx ratio — rather than the mesh’s generic success rate.
Your task:
- Define a Flagger
MetricTemplatethat queries Prometheus for the percentage of 5xx responses on the canary pods. - Add a
confirm-rolloutwebhook gate to thecheckoutCanary so it halts before the first traffic shift, and reference the metric template with a threshold of 1%. - Trigger a release, show it holding at the gate, open the gate, and watch it progress.
Done when: after a new image is set, kubectl -n prod get canary checkout shows phase Waiting and kubectl -n prod describe canary checkout shows a halt event referencing the approval webhook; after opening the gate the phase moves to Progressing and finally Succeeded, with kubectl -n prod get canary checkout -o jsonpath='{.status.canaryWeight}' back at 0. (5 points)
Show the worked solution
apiVersion: flagger.app/v1beta1
kind: MetricTemplate
metadata:
name: checkout-error-ratio
namespace: prod
spec:
provider:
type: prometheus
address: http://prometheus-operated.monitoring.svc:9090
query: |
100 * (
sum(
rate(
http_requests_total{
namespace="{{ namespace }}",
job="{{ target }}-canary",
status=~"5.."
}[{{ interval }}]
)
)
/
sum(
rate(
http_requests_total{
namespace="{{ namespace }}",
job="{{ target }}-canary"
}[{{ interval }}]
)
)
)apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
name: checkout
namespace: prod
spec:
provider: istio # must match the mesh/ingress actually installed
targetRef:
apiVersion: apps/v1
kind: Deployment
name: checkout
service:
port: 8080
targetPort: 8080
analysis:
interval: 30s
threshold: 5 # consecutive failed checks before rollback
maxWeight: 50
stepWeight: 10
metrics:
- name: checkout-error-ratio
templateRef:
name: checkout-error-ratio
namespace: prod
thresholdRange:
max: 1 # more than 1% 5xx on the canary halts it
interval: 1m
webhooks:
- name: "human approval"
type: confirm-rollout # blocks BEFORE any traffic shifts
url: http://flagger-loadtester.prod/gate/check # closed until someone opens it
- name: load-test
type: rollout # generates traffic so the metric has data
url: http://flagger-loadtester.prod/
timeout: 15s
metadata:
cmd: "hey -z 1m -q 10 -c 2 http://checkout-canary.prod:8080/"kubectl apply -f canary.yaml
kubectl -n prod set image deploy/checkout checkout=ghcr.io/acme/checkout:3.4.0
kubectl -n prod get canary checkout -w # STATUS Waiting — held at the gate
kubectl -n prod describe canary checkout | tail -20
# open the gate from inside the cluster (the loadtester serves the gate API)
kubectl -n prod exec -it deploy/flagger-loadtester -- \
curl -s -d '{"name": "checkout", "namespace": "prod"}' http://localhost:8080/gate/open
kubectl -n prod get canary checkout -w # Progressing → Promoting → Succeeded
kubectl -n prod exec -it deploy/flagger-loadtester -- \
curl -s -d '{"name": "checkout", "namespace": "prod"}' http://localhost:8080/gate/closeWhy: Flagger’s webhooks are not one thing — the type decides where in the rollout it runs, and picking the wrong one is the classic mistake. confirm-rollout runs before the canary scales up and before any traffic moves, which is what a compliance gate needs — and the load tester’s /gate/check endpoint is the manual gate, returning 403 until somebody posts to /gate/open (its /gate/approve and /gate/halt endpoints are always-yes and always-no fixtures, useful for testing the wiring but never for approval). pre-rollout runs after scale-up, rollout runs on every analysis interval (that is where load generation belongs), confirm-promotion gates the final cutover, and rollback can abort from outside. While a gate is closed the canary sits in Waiting and the failure threshold is not consumed, so a slow approver does not cause a rollback. The MetricTemplate is the escape from built-in metrics: it renders {{ target }}, {{ namespace }} and {{ interval }} into your own PromQL, so the canary is judged on the number the business actually cares about. Keep the load-test webhook even when real traffic exists — a canary receiving no requests produces no samples, the query returns nothing, and the analysis stalls rather than passing. Compare with the controller-driven approach in Argo Rollouts and the theory in CI/CD & progressive delivery.
“Four of those five are invisible to me and I feel every one. The project fence means I cannot accidentally take the cluster down at 4pm on a Friday — and honestly, thank you. The image automation means my patch releases just… arrive, with a commit I can revert like any other. And the approval gate means the scary release now has a person’s name attached to it instead of a shrug. None of this made me learn a new tool.”
Platform APIs & Self-Service — X6 to X10 (25 points)
☺ Like you’re 10: These five are about the new words you taught the cluster — translating an old word into a new one, generating objects from a template, writing them down in the team catalogue, sharing ownership of a page, and using a code generator so the words are always spelled the same.
The other 25% domain. Roughly half of it is mechanics you can drill to reflex; the rest is judgement about what to expose to developers and what to keep behind the curtain. Background: Platform APIs & operators, self-service & golden paths, IaC & control planes and developer experience.
X6 · Convert a custom resource between incompatible versions
Your Database API is moving from v1alpha1, where capacity was the single string field spec.size: small, to v1, where it is spec.resources.cpu and spec.resources.memory. The schemas are no longer compatible, so the “just change the apiVersion” trick you used last time would serve old clients an object with a field that no longer exists. A conversion webhook is already deployed in platform-system; it is not wired up.
Your task:
- Point the CRD at the conversion webhook: strategy
Webhook, the service reference, path and port, and the review versions it speaks. - Get a valid CA bundle in place using cert-manager’s CA injection rather than pasting base64 by hand.
- Prove the API server actually calls it: the same object must read back correctly under both versions.
Done when: kubectl get crd databases.platform.acme.io -o jsonpath='{.spec.conversion.strategy}' prints Webhook and {.spec.conversion.webhook.clientConfig.caBundle} is non-empty, and kubectl get databases.v1alpha1.platform.acme.io demo -o yaml shows spec.size while kubectl get databases.v1.platform.acme.io demo -o yaml shows spec.resources — same object, both readable. (5 points)
Show the worked solution
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.platform.acme.io
annotations:
# cainjector copies the CA from this Certificate's secret into spec.conversion
cert-manager.io/inject-ca-from: platform-system/database-conversion-tls
spec:
group: platform.acme.io
names:
kind: Database
plural: databases
singular: database
scope: Namespaced
conversion:
strategy: Webhook
webhook:
conversionReviewVersions: ["v1"]
clientConfig:
service:
namespace: platform-system
name: database-conversion
path: /convert
port: 443
versions:
- name: v1alpha1
served: true
storage: false
deprecated: true
deprecationWarning: "platform.acme.io/v1alpha1 Database is deprecated; use v1"
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [size]
properties:
size: { type: string, enum: [small, medium, large] }
- name: v1
served: true
storage: true # what actually gets persisted in etcd
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [resources]
properties:
resources:
type: object
required: [cpu, memory]
properties:
cpu: { type: string }
memory: { type: string }apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: database-conversion-tls
namespace: platform-system
spec:
secretName: database-conversion-tls
dnsNames:
- database-conversion.platform-system.svc
- database-conversion.platform-system.svc.cluster.local
issuerRef:
name: platform-ca
kind: ClusterIssuer
group: cert-manager.iokubectl apply -f database-crd.yaml
kubectl get crd databases.platform.acme.io -o jsonpath='{.spec.conversion.strategy}{"\n"}'
kubectl get crd databases.platform.acme.io -o jsonpath='{.spec.conversion.webhook.clientConfig.caBundle}' | head -c 40
kubectl -n demo apply -f database-v1.yaml
kubectl -n demo get databases.v1.platform.acme.io demo -o yaml # spec.resources
kubectl -n demo get databases.v1alpha1.platform.acme.io demo -o yaml # spec.size
# when it is broken, the error is always at read time:
# conversion webhook for platform.acme.io/v1, Kind=Database failed:
# Post "https://database-conversion.platform-system.svc:443/convert?timeout=30s":
# x509: certificate signed by unknown authority
kubectl -n platform-system logs deploy/database-conversion --tail=20Why: strategy: None only relabels apiVersion and hands back the stored object untouched — fine when versions are structurally identical, actively dangerous when they are not, which is this case. A conversion webhook is the supported answer, and three details decide whether it works. First, the API server calls it on every read and write of a non-storage version, including list and watch, so an unavailable webhook makes the entire kind unreadable — treat it as control-plane infrastructure, not a sidecar. Second, the caBundle must be trusted by the API server; cert-manager.io/inject-ca-from hands that job to cainjector so nothing expires silently at 03:00. Third, conversion is hub-and-spoke: your webhook must convert between every served version pair, and the cleanest implementation converts everything to and from the storage version. As on Set 2, exactly one version is storage: true, and removing an old version still requires rewriting stored objects first. More in Platform APIs & operators.
X7 · Compose with functions: templating plus auto-ready
Your XPostgres composition needs to emit a per-claim ConfigMap whose contents depend on fields of the claim — something the patch engine cannot express. Worse, claims never report READY=True even when everything under them is healthy, so the Backstage tile for “database ready” is permanently amber and developers have stopped trusting it.
Your task:
- Install the
function-go-templatingandfunction-auto-readyFunction packages and wait for both to be healthy. - Rewrite the Composition pipeline as two ordered steps: render the resources from a Go template using claim fields, then derive readiness.
- Create a claim and show the composite becoming
SYNCED=True READY=True.
Done when: kubectl get functions shows both packages INSTALLED=True HEALTHY=True, kubectl get composition xpostgres -o jsonpath='{.spec.pipeline[*].step}' prints render ready in that order, and kubectl get xpostgres shows the composite with both SYNCED and READY true after the composed resources come up. (6 points)
Show the worked solution
apiVersion: pkg.crossplane.io/v1 kind: Function metadata: name: function-go-templating spec: package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.2 --- apiVersion: pkg.crossplane.io/v1 kind: Function metadata: name: function-auto-ready spec: package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.4.1
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xpostgres
spec:
compositeTypeRef:
apiVersion: platform.acme.io/v1alpha1
kind: XPostgres
mode: Pipeline
pipeline:
- step: render
functionRef:
name: function-go-templating
input:
apiVersion: gotemplating.fn.crossplane.io/v1beta1
kind: GoTemplate
source: Inline
inline:
template: |
{{ $params := .observed.composite.resource.spec.parameters }}
apiVersion: kubernetes.crossplane.io/v1alpha2
kind: Object
metadata:
annotations:
# this annotation is the resource's stable identity across reconciles
gotemplating.fn.crossplane.io/composition-resource-name: connection-config
spec:
forProvider:
manifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .observed.composite.resource.metadata.name }}-db
namespace: {{ $params.namespace }}
data:
size: {{ $params.size | quote }}
region: {{ $params.region | quote }}
providerConfigRef:
name: kubernetes-provider
- step: ready
functionRef:
name: function-auto-ready # last step: derive XR readiness from childrenkubectl apply -f functions.yaml
kubectl get functions -w # both INSTALLED True / HEALTHY True
# render the pipeline locally before you touch the cluster — no claim, no waiting
crossplane render xr.yaml composition.yaml functions.yaml
kubectl apply -f composition.yaml
kubectl apply -f claim.yaml
kubectl get claim,composite,managed
kubectl get composition xpostgres -o jsonpath='{.spec.pipeline[*].step}{"\n"}'
kubectl describe xpostgres # events name the failing pipeline stepWhy: a pipeline is an ordered chain — each function receives the observed state plus the desired state produced so far, and returns a new desired state — which is why function-auto-ready goes last: it can only judge readiness once every other step has declared its resources. That function exists because a composition built purely from templates has no opinion about readiness, so the composite stays not-ready forever; auto-ready marks the XR ready when all composed resources report ready, which is exactly the signal a developer portal should display. function-go-templating earns its place wherever patches cannot reach — loops, conditionals, string building, whole resources that only exist for some inputs. The one detail that will bite you is the gotemplating.fn.crossplane.io/composition-resource-name annotation: it is how Crossplane matches a rendered resource to the one it created last time, and without it a re-render orphans the old resource and creates a duplicate. Finally, crossplane render runs the whole pipeline on your laptop against a fake XR — the fastest debugging loop in the entire domain. Background in Crossplane and IaC & control planes.
X8 · Backfill forty services into the Backstage catalog
New services created from the golden path register themselves. The forty repositories that predate it do not appear anywhere, so during an incident nobody can answer “who owns billing-api and where does it run?” without asking in Slack.
Your task:
- Write a
catalog-info.yamlforbilling-apithat declares a Component owned by a Group and belonging to a System, and the API entity it provides. - Add the annotations that link the entity to its runtime — Kubernetes workloads, its Argo CD application, and its TechDocs source.
- Register the file as a static catalog location and confirm the entity, its owner and its relations resolve — with no orphan warning.
Done when: curl -s http://localhost:7007/api/catalog/entities/by-name/component/default/billing-api returns the entity with spec.owner set to the group and a relations array containing ownedBy, partOf and providesApi, and the Component page in the UI shows the owner rather than “Unknown”. (5 points)
Show the worked solution
# billing-api/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: billing-api
description: Issues and reconciles customer invoices
annotations:
backstage.io/techdocs-ref: dir:.
backstage.io/kubernetes-id: billing-api # matches the k8s label selector
argocd/app-name: billing-api
github.com/project-slug: acme/billing-api
tags: [go, tier-1]
links:
- url: https://grafana.acme.io/d/billing
title: Dashboard
icon: dashboard
spec:
type: service
lifecycle: production
owner: group:default/payments # creates the ownedBy relation
system: billing # creates the partOf relation
providesApis:
- billing-api-http # creates the providesApi relation
---
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: billing-api-http
spec:
type: openapi
lifecycle: production
owner: group:default/payments
system: billing
definition:
$text: ./openapi.yaml
---
apiVersion: backstage.io/v1alpha1
kind: System
metadata:
name: billing
spec:
owner: group:default/payments# app-config.yaml — register the location and allow the kinds it introduces
catalog:
rules:
- allow: [Component, System, API, Group, User, Resource, Location]
locations:
- type: url
target: https://github.com/acme/billing-api/blob/main/catalog-info.yaml
# going forward, discover them automatically instead of listing forty files:
providers:
github:
acme:
organization: acme
catalogPath: /catalog-info.yaml
schedule:
frequency: { minutes: 30 }
timeout: { minutes: 3 }# validate the location before you commit to it — dryRun parses and reports errors
# without adding anything to the catalog
curl -s -X POST 'http://localhost:7007/api/catalog/locations?dryRun=true' \
-H 'Content-Type: application/json' \
-d '{"type":"url","target":"https://github.com/acme/billing-api/blob/main/catalog-info.yaml"}' | jq
curl -s http://localhost:7007/api/catalog/entities/by-name/component/default/billing-api | jq '.spec.owner, .relations'
curl -s "http://localhost:7007/api/catalog/entities/by-name/api/default/billing-api-http" | jq '.kind'
curl -s http://localhost:7007/api/catalog/locations | jq '.[].data.target'Why: a catalog entity is just YAML with apiVersion, kind, metadata and spec — the interesting part is that relations are derived, not written. You never author ownedBy; the processor derives it from spec.owner, and if that group does not exist the entity silently becomes an orphan rather than raising an error. That is why the done-when check looks at relations and not just at the entity existing. catalog.rules is the allow-list of kinds a location may introduce, so a location that “does nothing” is usually a kind that is not permitted. Annotations are the glue to everything else: backstage.io/kubernetes-id makes the Kubernetes plugin show live workloads, argocd/app-name shows sync status, backstage.io/techdocs-ref tells TechDocs where the docs live. Static locations are the right tool for a one-off backfill; the GitHub discovery provider is what stops the problem recurring. See Backstage and developer experience.
X9 · Settle a field-ownership fight with server-side apply
The platform’s sidecar-injection controller and the tenant’s CI both write to the api Deployment. Applies started failing with conflicts, and somebody “fixed” it by adding --force-conflicts to the pipeline — which now silently strips the platform’s resource limits on every deploy.
Your task:
- Reproduce the conflict with server-side apply, using a distinct field manager for each writer.
- Split ownership so the platform owns the container resources and the app team owns the image and replica count, and each can apply without forcing.
- Show per-field ownership on the live object, and hand one field over deliberately.
Done when: kubectl -n apps get deploy api --show-managed-fields -o json | jq -r '.metadata.managedFields[].manager' lists both platform-controller and app-team, both kubectl apply --server-side commands succeed with no --force-conflicts, and applying a field owned by the other manager still produces Apply failed with 1 conflict. (4 points)
Show the worked solution
# platform-base.yaml — the platform owns resources and securityContext only
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: apps
spec:
template:
spec:
containers:
- name: api
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: "1", memory: 512Mi }
---
# app-overlay.yaml — the app team owns the image and the replica count only
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: apps
spec:
replicas: 4
selector:
matchLabels: { app: api }
template:
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: ghcr.io/acme/api:1.4.9# order matters on a fresh cluster: the overlay is the only one of the two that carries
# spec.selector and the pod template labels, and a Deployment cannot be CREATED without
# them. Apply it first (or start from the Deployment that already exists), then layer
# the platform's fields on top — after that either may apply in any order.
kubectl apply --server-side --field-manager=app-team -f app-overlay.yaml
kubectl apply --server-side --field-manager=platform-controller -f platform-base.yaml
# who owns what, field by field
kubectl -n apps get deploy api --show-managed-fields -o json \
| jq '.metadata.managedFields[] | {manager, operation, time}'
# the conflict, reproduced: app-team tries to set a field platform-controller owns
kubectl apply --server-side --field-manager=app-team -f app-with-limits.yaml
# error: Apply failed with 1 conflict: conflict with "platform-controller"
# using apps/v1: .spec.template.spec.containers[name="api"].resources.limits.memory
# deliberate handover — this TAKES ownership, it does not merge
kubectl apply --server-side --force-conflicts --field-manager=app-team -f app-with-limits.yamlWhy: server-side apply moves field ownership into the API server: each manager records the exact set of fields it set, and a conflict is the API server telling you two managers disagree about one of them. The fix is almost never --force-conflicts — that is a takeover, and after it the losing manager’s next apply conflicts in the other direction, which is the ping-pong this task started with. The fix is to make each manifest contain only the fields its owner is responsible for. The trap that catches everyone: under SSA, removing a field from your manifest relinquishes it, and if nobody else owns it the field is deleted from the live object — so an “empty” overlay is not a no-op. Two practical notes for the exam: managedFields has been hidden from kubectl get -o yaml since 1.21, so you need --show-managed-fields; and Argo CD’s ServerSideApply=true sync option makes all of this concrete on a GitOps platform, with argocd.argoproj.io/manager naming the manager. Related field-ownership thinking in configuration management.
X10 · Generate a typed API with kubebuilder markers
Your team hand-writes CRD YAML, and the four CRDs the platform ships have drifted: two are missing the status subresource, one has printer columns that reference fields that no longer exist, and the validation in the YAML disagrees with the validation in the controller’s Go structs. You are moving to generated manifests.
Your task:
- Scaffold a project and a
RunnerAPI in groupplatform.acme.io, versionv1alpha1. - Express the contract entirely in markers on the Go types: a status subresource, two printer columns, an enum with a default, and a numeric range.
- Generate the CRD, install it, and prove the API server enforces what the markers said.
Done when: make manifests writes config/crd/bases/platform.acme.io_runners.yaml, kubectl get crd runners.platform.acme.io -o jsonpath='{.spec.versions[0].subresources}' shows {"status":{}}, kubectl explain runner.spec.pool shows the enum, and creating a Runner with pool: huge or replicas: 99 is rejected by the API server. (5 points)
Show the worked solution
mkdir -p platform-operator && cd platform-operator kubebuilder init --domain acme.io --repo github.com/acme/platform-operator kubebuilder create api --group platform --version v1alpha1 --kind Runner --resource --controller # → api/v1alpha1/runner_types.go
// api/v1alpha1/runner_types.go
type RunnerSpec struct {
// Pool selects the machine class the runners are placed on.
// +kubebuilder:validation:Enum=small;large
// +kubebuilder:default=small
Pool string `json:"pool"`
// Replicas is how many runners to keep warm.
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=50
// +kubebuilder:default=1
Replicas int32 `json:"replicas"`
// +kubebuilder:validation:MinLength=1
Repository string `json:"repository"`
}
type RunnerStatus struct {
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=rn,categories=platform
// +kubebuilder:printcolumn:name="Pool",type=string,JSONPath=`.spec.pool`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type Runner struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec RunnerSpec `json:"spec,omitempty"`
Status RunnerStatus `json:"status,omitempty"`
}make manifests generate # controller-gen writes the CRD + deepcopy
ls config/crd/bases/ # platform.acme.io_runners.yaml
make install # kubectl apply -f config/crd/bases
kubectl get crd runners.platform.acme.io -o jsonpath='{.spec.versions[0].subresources}{"\n"}'
kubectl explain runner.spec.pool
kubectl get runners # POOL / READY / AGE columns
kubectl apply -f - <<'EOF'
apiVersion: platform.acme.io/v1alpha1
kind: Runner
metadata: { name: ci, namespace: default }
spec: { pool: huge, replicas: 99, repository: acme/api }
EOF
# The Runner "ci" is invalid:
# spec.pool: Unsupported value: "huge": supported values: "small", "large"
# spec.replicas: Invalid value: 99: spec.replicas in body should be less than or equal to 50Why: markers make the Go type the single source of truth, and controller-gen derives the CRD from it — which is precisely what stops the drift you started with, because there is no second place to forget to update. Note where each marker lives: +kubebuilder:validation:* sits on the field it constrains, while +kubebuilder:object:root, +kubebuilder:subresource:status, +kubebuilder:resource and +kubebuilder:printcolumn sit on the type. Two consequences worth knowing cold: subresource:status means writes to .status must go through /status, so a controller that updates the main resource will silently not persist status changes; and a field without omitempty is always serialised, which turns “unset” into a real zero value in the API. Finally, generation is not optional hygiene — make manifests must be run and committed, or the CRD in config/ and the type in api/ disagree again by the next sprint. See kubebuilder.
Before you sit the paper, do a pure-speed warm-up with a hard rule: kubectl explain is your only reference. From a cold terminal, produce a valid skeleton of an AppProject, an ImagePolicy, a CronWorkflow and a ConstraintTemplate — three minutes each, no vendor documentation, because on the day you will not have any. Time each one. Whichever takes longest is your highest-value study target for the week ahead, ahead of any concept on any lesson page. kubectl api-resources | grep -i <word> to find the group, then kubectl explain <kind>.spec --recursive for the shape: that two-command loop is the single most transferable skill in this exam.
Observability & Operations — X11 to X13 (20 points)
☺ Like you’re 10: These three are about finding the needle in a mountain of log lines, getting charts for free out of data you already collect, and working out why the alarm rang in one room but nobody heard it in the other.
Twenty percent of the paper, and the domain that most rewards a fixed method. When a triage task starts, resist the urge to guess — go through the same evidence order every time. Background: observability, reliability & incidents and the troubleshooting playbook.
X11 · Find the failure in Loki with LogQL
Checkout errors spiked at 14:05 and were gone by 14:20. The pods have since been recycled, so kubectl logs has nothing; everything is in Loki. The on-call engineer has been scrolling the Grafana Explore pane by hand for ten minutes and the incident channel is waiting.
Your task:
- Write a LogQL query that returns only error-level lines from the
checkoutapp in namespaceprod, parsing the JSON payload and filtering on a parsed field. - Turn it into two metric queries: the per-error-code rate over five minutes, and the p95 of a numeric field extracted from the log line.
- Promote the first one into a Loki ruler alerting rule with a
forclause and routing labels.
Done when: logcli query --since=15m '<your selector>' returns only matching lines, logcli query 'sum by (code) (rate(...[5m]))' returns a vector with one series per error code, and the rule appears in curl -s http://loki-gateway.loki/prometheus/api/v1/rules | jq '.data.groups[].rules[].name'. (7 points)
Show the worked solution
# 1. log query — stream selector, then line filter, then parser, then label filter
{namespace="prod", app="checkout"}
|= "error"
| json
| level = "error"
| code =~ "5.."
| line_format "{{.ts}} {{.code}} {{.msg}}"
# 2a. rate of errors per response code
sum by (code) (
rate({namespace="prod", app="checkout"} |= "error" | json | level = "error" [5m])
)
# 2b. p95 of a numeric field lifted out of the line with unwrap
quantile_over_time(0.95,
{namespace="prod", app="checkout"}
| json
| unwrap duration_ms [5m]
) by (route)# loki ruler rule — Prometheus rule format, LogQL expression
groups:
- name: checkout
interval: 1m
rules:
- alert: CheckoutErrorLogRateHigh
expr: |
sum(
rate({namespace="prod", app="checkout"} |= "error" | json | level = "error" [5m])
) > 5
for: 5m
labels:
severity: critical
team: payments
annotations:
summary: "checkout is logging more than 5 errors/s"
runbook_url: "https://runbooks.acme.io/checkout-errors"export LOKI_ADDR=http://loki-gateway.loki
logcli labels # which labels exist at all
logcli labels namespace # and their values
logcli series '{namespace="prod"}' # which streams exist
logcli query --since=15m --limit=200 \
'{namespace="prod", app="checkout"} | json | level = "error"'
logcli query --since=1h \
'sum by (code) (rate({namespace="prod", app="checkout"} | json | level = "error" [5m]))'
kubectl -n loki get cm loki-rules -o yaml
curl -s $LOKI_ADDR/prometheus/api/v1/rules | jq '.data.groups[].rules[].name'Why: LogQL is read left to right and the order is a performance contract, not a style choice. The stream selector in braces is the only part backed by an index, so a query without tight label matchers scans every chunk in the time range and will time out on a real cluster. Next come line filters (|=, !=, |~, !~) which are cheap byte matching over raw lines — put them before parsers so the expensive step sees fewer lines. Then parsers (json, logfmt, pattern, regexp) which extract labels, and only then label filters on what they extracted. A metric query wraps a log query in a range aggregation — rate and count_over_time count lines, while unwrap turns an extracted label into a numeric sample so quantile_over_time, sum_over_time and friends can work on values. The cardinality rule matters more here than anywhere: never promote a high-cardinality field such as a user or trace id to a stream label — keep it in the line and filter on it after parsing, or you will melt the index. See Loki and Grafana.
X12 · Derive RED metrics from traces with the spanmetrics connector
Thirty services emit traces and nothing else. Product wants request-rate, error-rate and latency dashboards for all of them; adding a Prometheus client library to thirty codebases is a quarter of work nobody has. Separately, the collector was quietly dropping data during last week’s incident and nobody could tell.
Your task:
- Add a
spanmetricsconnector so the traces pipeline also feeds a metrics pipeline with call counts and latency histograms. - Attach Kubernetes identity to every span with the
k8sattributesprocessor, including the RBAC it needs, and expose the derived metrics for scraping. - Turn on the collector’s own telemetry and prove nothing is being dropped.
Done when: curl -s http://otel-collector.observability:8889/metrics | grep traces_span_metrics_calls_total returns series labelled with service_name and span_name, curl -s http://otel-collector.observability:8888/metrics | grep -E 'otelcol_(processor_dropped|exporter_send_failed)' shows zero, and spans in the backend carry k8s.namespace.name and k8s.deployment.name. (7 points)
Show the worked solution
receivers:
otlp:
protocols:
grpc: { endpoint: 0.0.0.0:4317 }
http: { endpoint: 0.0.0.0:4318 }
connectors:
spanmetrics:
namespace: traces.span.metrics # → traces_span_metrics_calls_total
histogram:
explicit:
buckets: [10ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 5s]
dimensions:
- name: http.route
- name: http.response.status_code
exclude_dimensions: [span.kind]
metrics_flush_interval: 15s
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 75
spike_limit_percentage: 15
k8sattributes:
auth_type: serviceAccount
passthrough: false
extract:
metadata:
- k8s.namespace.name
- k8s.pod.name
- k8s.deployment.name
- k8s.node.name
pod_association:
- sources:
- from: resource_attribute
name: k8s.pod.ip
- sources:
- from: connection # fallback: the sender's source IP
batch:
timeout: 5s
send_batch_size: 1024
exporters:
otlp/tempo:
endpoint: tempo.observability.svc:4317
tls: { insecure: true }
prometheus:
endpoint: 0.0.0.0:8889
resource_to_telemetry_conversion:
enabled: true # resource attrs become metric labels
service:
telemetry:
metrics:
level: detailed # collector self-metrics on :8888
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/tempo, spanmetrics] # connector as an EXPORTER here
metrics/spanmetrics:
receivers: [spanmetrics] # …and as a RECEIVER here
processors: [batch]
exporters: [prometheus]# k8sattributes cannot work without this — the silent-failure trap
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: otel-collector-k8sattributes
rules:
- apiGroups: [""]
resources: ["pods", "namespaces", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["replicasets"] # deployment name is derived via the ReplicaSet
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: otel-collector-k8sattributes
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: otel-collector-k8sattributes
subjects:
- kind: ServiceAccount
name: otel-collector
namespace: observabilitykubectl -n observability create cm otel-collector-conf --from-file=config.yaml \ --dry-run=client -o yaml | kubectl apply -f - kubectl apply -f collector-rbac.yaml kubectl -n observability rollout restart deploy/otel-collector kubectl -n observability logs deploy/otel-collector | tail -30 kubectl -n observability port-forward deploy/otel-collector 8889:8889 8888:8888 & curl -s http://localhost:8889/metrics | grep traces_span_metrics_calls_total | head curl -s http://localhost:8888/metrics | grep -E 'otelcol_(processor_dropped|exporter_send_failed)'
Why: a connector is the piece most candidates have never used: it is an exporter on one pipeline and a receiver on another, which is how you cross signal types inside the collector. spanmetrics consumes spans and emits call counts and latency histograms, giving you RED metrics for thirty services without touching a line of application code — the labels come from span attributes you list under dimensions, so keep that list short or you will create a cardinality problem in Prometheus instead of a dashboard. k8sattributes is what makes those metrics joinable with everything else in the cluster, and it fails in the most confusing way possible: with no RBAC it starts cleanly, enriches nothing, and the missing labels look like an instrumentation bug. The prometheus exporter exposes a scrape endpoint (pull); use prometheusremotewrite when you need to push. And the collector's own :8888 metrics are the answer to “is it dropping data?” — a question you want answered before an incident, not during one. Note that recent collector releases configure the self-telemetry endpoint through service.telemetry.metrics.readers; check what the binary in front of you accepts. See OpenTelemetry and Prometheus.
X13 · The alert fired and nobody was paged
The postmortem for last Thursday found KubePodCrashLooping firing continuously for forty minutes with no page and no chat message. Alertmanager was up the whole time. The alert is plainly visible as firing in the Prometheus UI, and the on-call rota was correct.
Your task:
- Establish where the alert stops, working from evidence along the four hops: rule evaluation, Alertmanager discovery, delivery to Alertmanager, and routing to a receiver.
- Fix the root cause with the smallest correct change.
- Prove an alert now reaches the intended receiver.
Done when: curl -s $PROM/api/v1/alertmanagers | jq '.data.activeAlertmanagers | length' is greater than zero, amtool alert query alertname=KubePodCrashLooping lists the alert as active, amtool config routes test severity=critical team=platform returns the expected receiver, and amtool silence query shows no silence matching it. (6 points)
Show the worked solution
export PROM=http://prometheus-operated.monitoring.svc:9090
export AM=http://alertmanager-operated.monitoring.svc:9093
# HOP 1 — is the rule loaded and evaluating? (a rule the operator never picked up
# simply does not exist, and nothing anywhere says so)
curl -s $PROM/api/v1/rules \
| jq '.data.groups[].rules[] | select(.name=="KubePodCrashLooping") | {state, health, lastError}'
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.ruleSelector}{"\n"}'
kubectl get prometheusrule -A --show-labels
# HOP 2 — does Prometheus know about any Alertmanager?
curl -s $PROM/api/v1/alertmanagers | jq '.data.activeAlertmanagers'
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.alerting}{"\n"}'
# HOP 3 — did the alert actually arrive?
amtool --alertmanager.url=$AM alert query
amtool --alertmanager.url=$AM alert query alertname=KubePodCrashLooping
# HOP 4 — where would it route, and is anything swallowing it?
amtool config routes test --config.file=alertmanager.yaml severity=critical team=platform
amtool --alertmanager.url=$AM silence query
kubectl -n monitoring logs alertmanager-main-0 -c alertmanager | grep -i 'notify.*error'# the four root causes this evidence order separates, and the smallest fix for each:
# (a) the PrometheusRule was never loaded — its labels do not match spec.ruleSelector
kubectl -n monitoring label prometheusrule platform-alerts release=kube-prometheus-stack
# (b) Prometheus discovered no Alertmanager — the alerting block points at nothing
kubectl -n monitoring patch prometheus k8s --type=merge -p '{"spec":{"alerting":{"alertmanagers":[{"namespace":"monitoring","name":"alertmanager-operated","port":"web"}]}}}'
# (c) a silence from a previous incident, opened "for an hour", never expired
amtool --alertmanager.url=$AM silence query --expired=false
amtool --alertmanager.url=$AM silence expire 4f8f0c1e-1e4b-4c2a-9a5d-0f8a1c7b2d33
# (d) it routed to a receiver with no working integration — the logs are the only tell
kubectl -n monitoring logs alertmanager-main-0 -c alertmanager | grep -i 'notify'# prove the whole path end to end, without waiting for a real failure amtool --alertmanager.url=$AM alert add \ alertname=SmokeTest severity=critical team=platform \ --annotation=summary="pipeline smoke test" --end="$(date -u -d '+5 min' +%Y-%m-%dT%H:%M:%SZ)" amtool --alertmanager.url=$AM alert query alertname=SmokeTest
Why: an alert crosses four boundaries and each one has its own selector, which is why guessing is so unproductive here. /api/v1/rules proves the rule is loaded and evaluating — under the Prometheus Operator a PrometheusRule whose labels miss spec.ruleSelector is simply never loaded, and there is no error anywhere to find. /api/v1/alertmanagers proves discovery: Prometheus can evaluate perfectly and have nowhere to send. amtool alert query proves delivery. Only then does routing matter, and amtool config routes test answers it deterministically instead of by reading YAML indentation. Silences deserve their own paragraph: a silenced alert is still firing everywhere you look — in the rule state, in the API, in amtool alert query — and nothing about it says “this is being suppressed,” which makes an unexpired silence the single most common invisible cause of a missed page. That is why every silence needs a comment and the shortest duration that could possibly work. Full decision tree in the troubleshooting playbook and triaging delivery.
Platform Architecture & Infrastructure — X14 to X16 (15 points)
☺ Like you’re 10: These three are about a box that will not attach to the shelf, an important guest who still cannot get a seat even though they are top of the list, and making sure the building passes never expire at three in the morning.
Three self-contained tasks with good points-per-minute — do not leave them unattempted because they sit late in the paper. Two are triage, which means the win comes from a fixed evidence order rather than from knowing one manifest. Background: platform architecture, storage & state, scaling & scheduling and the Kubernetes substrate.
X14 · A PersistentVolumeClaim that will not bind
The timescale StatefulSet has been stuck for twenty minutes. timescale-0 is Pending, its claim data-timescale-0 is Pending, and the developer has already deleted and recreated the pod twice. Nothing in the application logs, because there is no container yet.
Your task:
- Determine why the claim will not bind, from events and the StorageClass rather than by guessing.
- Apply the smallest correct fix — remembering which fields on a bound claim and a StatefulSet are immutable.
- Show the pod Running and the volume bound.
Done when: kubectl -n data get pvc data-timescale-0 -o jsonpath='{.status.phase}' prints Bound, kubectl -n data get pod timescale-0 shows Running with 1/1 ready, and kubectl get pv shows a volume whose CLAIM column is data/data-timescale-0. (5 points)
Show the worked solution
# fixed evidence order — the events almost always name the cause outright
kubectl -n data describe pvc data-timescale-0 | tail -20
# Warning ProvisioningFailed persistentvolume-controller
# storageclass.storage.k8s.io "fast" not found
kubectl -n data describe pod timescale-0 | grep -A8 Events
# Warning FailedScheduling pod has unbound immediate PersistentVolumeClaims
kubectl get sc
kubectl get sc -o custom-columns='NAME:.metadata.name,PROV:.provisioner,BINDING:.volumeBindingMode,DEFAULT:.metadata.annotations.storageclass\.kubernetes\.io/is-default-class'
kubectl get pv
kubectl -n data get pvc data-timescale-0 -o jsonpath='{.spec.storageClassName}{" "}{.spec.accessModes}{" "}{.spec.resources.requests.storage}{"\n"}'
kubectl -n kube-system get pods -l app=local-path-provisioner # is the provisioner alive?# (a) the named StorageClass does not exist on this cluster
# storageClassName is IMMUTABLE on a claim, and volumeClaimTemplates are immutable
# on a StatefulSet — so you recreate, keeping the pods alive with --cascade=orphan
kubectl -n data delete sts timescale --cascade=orphan
kubectl -n data delete pvc data-timescale-0
# …edit volumeClaimTemplates to a class that exists, then:
kubectl apply -f timescale-sts.yaml
# (b) the claim omitted storageClassName and there is no default class
kubectl patch sc standard -p \
'{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
# (only one class may hold this annotation — clear it from the old one first)
# (c) a hand-made PV exists but does not match: capacity too small, wrong accessModes,
# or a stale claimRef from a deleted claim
kubectl get pv pv-timescale -o jsonpath='{.spec.capacity.storage}{" "}{.spec.accessModes}{" "}{.spec.claimRef.name}{"\n"}'
kubectl patch pv pv-timescale --type=json -p '[{"op":"remove","path":"/spec/claimRef"}]'kubectl -n data get pvc data-timescale-0 -o jsonpath='{.status.phase}{"\n"}' # Bound
kubectl -n data get pod timescale-0
kubectl get pvWhy: binding is a match on four things — StorageClass, access modes, requested capacity and any selector — plus a fifth that decides when the match is attempted. That fifth is volumeBindingMode, and it changes what “Pending” means. With Immediate, a Pending claim is a real failure and the PVC events name it. With WaitForFirstConsumer, a Pending claim is normal until a pod is scheduled — so if the pod is unschedulable for an unrelated reason (a taint, insufficient CPU, a node selector), you get a deadlock that reads like a storage bug: the pod waits for the volume, the volume waits for the pod. Always check the pod’s events as well as the claim’s. The immutability trap is what separates the five-point answer from the two-point one: spec.storageClassName cannot be edited on an existing claim, and volumeClaimTemplates cannot be edited on an existing StatefulSet, so the fix is delete-and-recreate — with --cascade=orphan so running pods survive the StatefulSet being replaced. More in storage & state and triaging workloads.
X15 · A high-priority pod that preemption cannot rescue
You adopted PriorityClasses last month, yet the ingress controller’s replacement pod has been Pending for nine minutes on a full cluster. Its events read: 0/4 nodes are available: 4 Insufficient cpu. preemption: 0/4 nodes are available: 4 No preemption victims found for incoming pod. Batch work is still running happily on every node.
Your task:
- Work out which of the reasons preemption declines applies here — the incoming pod’s own policy, victims of equal or higher priority, PDB-protected victims, or a request no eviction can satisfy.
- Fix it so the pod schedules, without switching protections off across the whole cluster.
- Prove the pod is Running and record what was preempted.
Done when: kubectl -n platform get pod -l app=ingress -o jsonpath='{.items[0].status.phase}' prints Running, kubectl get events -A --field-selector reason=Preempted names the victim pod, and kubectl -n platform get pod -l app=ingress -o jsonpath='{.items[0].spec.priority}' shows the expected value. (5 points)
Show the worked solution
# 1. what priority and policy does the PENDING pod actually have?
kubectl -n platform get pod -l app=ingress \
-o jsonpath='{.items[0].spec.priorityClassName}{" prio="}{.items[0].spec.priority}{" policy="}{.items[0].spec.preemptionPolicy}{"\n"}'
kubectl get priorityclass
# 2. what priority do the candidate victims have? ties are never preempted
kubectl get pods -A --sort-by=.spec.priority \
-o custom-columns='NS:.metadata.namespace,POD:.metadata.name,PRIO:.spec.priority,NODE:.spec.nodeName' | tail -20
# 3. is a PodDisruptionBudget standing in the way?
kubectl get pdb -A
kubectl -n batch get pdb cruncher -o jsonpath='{.status.disruptionsAllowed}{"\n"}'
# 4. did the scheduler already pick a victim and is simply waiting?
kubectl -n platform get pod -l app=ingress -o jsonpath='{.items[0].status.nominatedNodeName}{"\n"}'
kubectl -n platform describe pod -l app=ingress | grep -A10 Events# the four causes and their smallest fixes:
# (a) the incoming pod's class says preemptionPolicy: Never — it queues first but
# may never evict anything. Give the platform component a preempting class.
kubectl get priorityclass platform-critical -o jsonpath='{.preemptionPolicy}{"\n"}'
kubectl -n platform patch deploy ingress --type=merge \
-p '{"spec":{"template":{"spec":{"priorityClassName":"platform-critical"}}}}'
# (b) every candidate victim has the SAME priority as the incoming pod — a tie is
# never a victim. Lower the batch class so a gap exists.
kubectl delete priorityclass batch-background
kubectl apply -f - <<'EOF'
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: batch-background
value: 100
globalDefault: false
preemptionPolicy: Never
description: "Best-effort batch. Waits for room; never evicts anything."
EOF
kubectl -n batch rollout restart deploy/cruncher # priority is set at admission
# (c) a PDB with disruptionsAllowed 0 makes every victim set violate a budget
kubectl -n batch patch pdb cruncher --type=merge -p '{"spec":{"minAvailable":"50%"}}'
# (d) the request is larger than any node can free — the pod, not the cluster, is wrong
kubectl -n platform get deploy ingress -o jsonpath='{.spec.template.spec.containers[0].resources.requests}{"\n"}'
kubectl describe node | grep -A6 'Allocated resources'kubectl -n platform get pod -l app=ingress -w
kubectl get events -A --field-selector reason=Preempted
kubectl -n platform get pod -l app=ingress -o jsonpath='{.items[0].spec.priority}{"\n"}'Why: priority and preemption are two mechanisms and this task only fails if you conflate them. Priority orders the scheduling queue: a higher-priority pending pod is considered before a lower one. Preemption is what happens when the highest-priority pod still does not fit, and it declines for four documented reasons — the incoming pod has preemptionPolicy: Never; no set of victims on any single node would make the pod fit; every candidate victim has equal or higher priority, since ties are never preempted; or the only viable victim sets violate a PodDisruptionBudget. That last one is best-effort: the scheduler prefers victim sets that break no budget and will violate one rather than leave a pod pending forever, but a budget at zero allowed disruptions makes it try much harder to look elsewhere first. The tell that preemption did succeed is status.nominatedNodeName — the scheduler has reserved a node and is waiting for graceful termination, so the right response is patience, not another fix. And remember priority is resolved at admission: changing a PriorityClass does not reprice running pods, which is why the batch fix ends with a rollout restart. More in scaling & scheduling.
X16 · Automate platform TLS with cert-manager
The certificate for the platform’s mutating webhook was generated by hand eighteen months ago and expired at 03:00 on a Sunday. Because the webhook’s failurePolicy is Fail, every pod creation in the cluster was rejected until someone woke up. The fix is not a longer certificate — it is one that renews itself.
Your task:
- Build the issuer chain: a self-signed root
ClusterIssuer, a CACertificate, and a CAClusterIssuerthat signs from it. - Issue a serving
Certificatefor the webhook service with an explicitdurationandrenewBefore. - Inject the CA into the
MutatingWebhookConfigurationautomatically, and prove the certificate is Ready and renewable.
Done when: kubectl -n platform-system get certificate webhook-tls -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True, kubectl -n platform-system get secret webhook-tls -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -dates -subject -ext subjectAltName shows the two service SANs and a 90-day window, and kubectl get mutatingwebhookconfiguration platform -o jsonpath='{.webhooks[0].clientConfig.caBundle}' is non-empty. (5 points)
Show the worked solution
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: selfsigned-root
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: platform-ca
namespace: cert-manager # a ClusterIssuer reads its secret from
spec: # --cluster-resource-namespace (default: cert-manager)
isCA: true
commonName: platform-ca
secretName: platform-ca-tls
duration: 43800h # 5 years
renewBefore: 720h # 30 days
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: selfsigned-root
kind: ClusterIssuer
group: cert-manager.io
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: platform-ca
spec:
ca:
secretName: platform-ca-tls
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: webhook-tls
namespace: platform-system
spec:
secretName: webhook-tls
duration: 2160h # 90 days
renewBefore: 720h # renew a third of the way out, not the night before
dnsNames:
- platform-webhook.platform-system.svc
- platform-webhook.platform-system.svc.cluster.local
usages: [server auth]
issuerRef:
name: platform-ca
kind: ClusterIssuer
group: cert-manager.ioapiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: platform
annotations:
cert-manager.io/inject-ca-from: platform-system/webhook-tls # cainjector fills caBundle
webhooks:
- name: inject.platform.acme.io
admissionReviewVersions: ["v1"]
sideEffects: None
failurePolicy: Fail
clientConfig:
service:
name: platform-webhook
namespace: platform-system
path: /mutate
port: 443
rules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
scope: Namespaced
namespaceSelector:
matchLabels:
platform.acme.io/inject: "true" # never match every namespacekubectl apply -f tls.yaml
kubectl -n platform-system get certificate,certificaterequest
kubectl -n platform-system describe certificate webhook-tls | tail -15
kubectl -n platform-system get secret webhook-tls \
-o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -dates -subject -ext subjectAltName
kubectl get mutatingwebhookconfiguration platform \
-o jsonpath='{.webhooks[0].clientConfig.caBundle}' | head -c 40
# force a renewal and watch the secret change without touching the webhook config
cmctl renew webhook-tls -n platform-system
cmctl status certificate webhook-tls -n platform-systemWhy: cert-manager turns a certificate into a reconciled object: the Certificate is desired state, the Secret is actual state, and renewal happens at renewBefore without anyone being woken. Two details decide whether this works on the day. First, a ClusterIssuer of kind ca looks for its signing secret in the cluster resource namespace — the namespace cert-manager itself runs in, set by --cluster-resource-namespace — not in the namespace of the Certificate that consumes it. Putting the CA secret in platform-system and wondering why the issuer is not ready is the single most common cert-manager mistake. Second, cert-manager.io/inject-ca-from is served by cainjector, a separate deployment: if the annotation is right and the caBundle stays empty, check that cainjector is running before you doubt the annotation. Set renewBefore to roughly a third of duration so a failed renewal has time to be noticed and retried, and keep webhook namespaceSelectors tight — a fail-closed webhook that matches every namespace is a cluster-wide outage waiting for its certificate to expire. See cert-manager and secrets management.
Security & Policy Enforcement — X17 to X18 (15 points)
☺ Like you’re 10: These two are about house rules that need to look around the whole house before deciding, and about refusing to let in a parcel unless it has the right seal on it.
Only two tasks, but fifteen points — the best points-per-task on the paper, and the ones candidates most often never reach. Never leave these unattempted. Background: security & policy enforcement, governance & compliance and Sigstore & cosign.
X17 · A Gatekeeper policy that has to look at the rest of the cluster
Two teams claimed the same ingress host last month; the second Ingress silently took over routing for the first and a payment flow broke for six hours. No single-object policy can catch this, because deciding whether a host is a duplicate requires knowing about every other Ingress in the cluster.
Your task:
- Configure Gatekeeper to replicate
Ingressobjects into the OPA cache so policy can read them. - Write a
ConstraintTemplatethat rejects an Ingress whose host is already used by a different Ingress, and a Constraint that applies it. - Roll it out in
dryrun, read the violations it finds, then switch todenyand prove a duplicate is rejected.
Done when: kubectl get k8suniqueingresshost.constraints.gatekeeper.sh unique-ingress-host -o jsonpath='{.status.totalViolations}' reports the pre-existing duplicates while in dryrun, and after switching enforcementAction to deny, applying a second Ingress with an already-used host fails with your message while a unique host still applies cleanly. (8 points)
Show the worked solution
# 1. without this, data.inventory is empty and the policy silently never fires
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
name: config
namespace: gatekeeper-system
spec:
sync:
syncOnly:
- group: "networking.k8s.io"
version: "v1"
kind: "Ingress"apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8suniqueingresshost
spec:
crd:
spec:
names:
kind: K8sUniqueIngressHost
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8suniqueingresshost
identical(obj, review) {
obj.metadata.namespace == review.object.metadata.namespace
obj.metadata.name == review.object.metadata.name
}
violation[{"msg": msg}] {
input.review.kind.kind == "Ingress"
re_match("^(extensions|networking.k8s.io)$", input.review.kind.group)
host := input.review.object.spec.rules[_].host
other := data.inventory.namespace[ns][otherapiversion]["Ingress"][name]
re_match("^(extensions|networking.k8s.io)/.+$", otherapiversion)
not identical(other, input.review)
other.spec.rules[_].host == host
msg := sprintf("ingress host %v is already claimed by %v/%v", [host, ns, name])
}apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sUniqueIngressHost
metadata:
name: unique-ingress-host
spec:
enforcementAction: dryrun # measure first — flip to deny only when clean
match:
kinds:
- apiGroups: ["networking.k8s.io"]
kinds: ["Ingress"]
excludedNamespaces: ["kube-system", "gatekeeper-system"]kubectl apply -f gatekeeper-config.yaml
kubectl apply -f constrainttemplate.yaml
kubectl get constrainttemplate k8suniqueingresshost -o jsonpath='{.status.created}{"\n"}' # true
kubectl apply -f constraint.yaml
# audit runs on a timer (60s by default) and writes what it found into status
kubectl get k8suniqueingresshost unique-ingress-host \
-o jsonpath='{.status.totalViolations}{"\n"}'
kubectl get k8suniqueingresshost unique-ingress-host -o yaml | grep -A12 violations
# only now, enforce
kubectl patch k8suniqueingresshost unique-ingress-host --type=merge \
-p '{"spec":{"enforcementAction":"deny"}}'
kubectl -n team-b apply -f duplicate-ingress.yaml
# Error from server (Forbidden): admission webhook "validation.gatekeeper.sh" denied
# the request: [unique-ingress-host] ingress host shop.acme.io is already claimed by team-a/shopWhy: Rego normally sees exactly one thing — input.review.object, the object being admitted. A referential constraint needs more, and data.inventory is how Gatekeeper provides it: the Config resource tells Gatekeeper which kinds to replicate into the OPA cache, and only replicated kinds are visible. Miss that step and the policy is not broken in any observable way — it simply never finds a duplicate, which is the worst possible failure mode for a security control. Two properties follow from the cache being a cache. It is eventually consistent, so two identical Ingresses created in the same instant can both be admitted; referential policy is a strong guardrail, not a distributed lock. And the audit controller re-evaluates everything on a timer, writing results to status.violations (capped by constraintViolationsLimit), which is what makes dryrun genuinely useful: you get the full list of existing offenders before you break anybody’s deploy. dryrun → warn → deny is the only safe rollout order for any admission policy, in any engine. Compare with the same idea in Kyverno and the single-object version in OPA Gatekeeper.
X18 · Refuse unsigned images at the cluster boundary
Your pipeline signs every image with cosign and pushes the signature alongside it. Nothing in the cluster checks. Last Tuesday an engineer deployed an image built on their laptop straight into prod, and the only reason anyone noticed was the registry bill. Signing without verification is a ritual, not a control.
Your task:
- Write a Kyverno
ClusterPolicythat verifies a cosign signature from your public key for every image matchingghcr.io/acme/*inprodandstaging, starting inAudit. - Require that unmatched images are rejected rather than ignored, and that the admitted image is rewritten to the digest that was actually verified.
- Switch to
Enforceand prove a signed image is admitted with a digest while an unsigned one is refused.
Done when: kubectl -n prod run signed --image=ghcr.io/acme/api:1.4.9 --restart=Never succeeds and kubectl -n prod get pod signed -o jsonpath='{.spec.containers[0].image}' contains @sha256:; kubectl -n prod run unsigned --image=ghcr.io/acme/scratchpad:dev --restart=Never is refused with your message; and from the earlier Audit pass, kubectl -n prod get policyreport -o wide shows a pass for the signed Pod and a fail for the unsigned one — once the policy is enforcing the refused Pod never exists, so nothing can report on it. (7 points)
Show the worked solution
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-acme-images
spec:
validationFailureAction: Audit # Enforce only after the reports come back clean
background: false # image verification is admission-time only
failurePolicy: Fail # Kyverno down = no unverified images admitted
webhookTimeoutSeconds: 30 # registry round-trips are not instant
rules:
- name: verify-signature
match:
any:
- resources:
kinds: [Pod]
namespaces: [prod, staging]
verifyImages:
- imageReferences:
- "ghcr.io/acme/*"
required: true # an image with no matching attestor is REJECTED
mutateDigest: true # rewrite the tag to the digest we just verified
verifyDigest: true
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEabc123...replace...with...yours
-----END PUBLIC KEY-----
rekor:
url: https://rekor.sigstore.dev# keyless variant — no key to rotate, identity comes from the CI workload itself
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/acme/*/.github/workflows/*@refs/heads/main"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev# prove the signature exists before you blame the policy
cosign verify --key cosign.pub ghcr.io/acme/api:1.4.9
kubectl apply -f verify-acme-images.yaml
kubectl get clusterpolicy verify-acme-images # READY True before testing anything
# audit first: create both Pods while nothing is blocked, and read what it WOULD have done
kubectl -n prod run signed --image=ghcr.io/acme/api:1.4.9 --restart=Never
kubectl -n prod run unsigned --image=ghcr.io/acme/scratchpad:dev --restart=Never
kubectl -n prod get policyreport -o wide # one pass, one fail
kubectl -n prod get policyreport -o yaml | grep -B4 -A6 'result: fail'
kubectl -n prod delete pod signed unsigned
# then enforce
kubectl patch clusterpolicy verify-acme-images --type=merge \
-p '{"spec":{"validationFailureAction":"Enforce"}}'
kubectl -n prod run signed --image=ghcr.io/acme/api:1.4.9 --restart=Never
kubectl -n prod get pod signed -o jsonpath='{.spec.containers[0].image}{"\n"}'
# ghcr.io/acme/api:1.4.9@sha256:9b2c…
kubectl -n prod run unsigned --image=ghcr.io/acme/scratchpad:dev --restart=Never
# Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
# failed to verify image ghcr.io/acme/scratchpad:dev: .attestors[0].entries[0].keys:
# no matching signatures foundWhy: signing in CI proves provenance; verifying at admission is what turns that proof into a control, and the two must be the same key or the same identity. Three flags carry most of the marks. required: true is the difference between a policy and a decoration: without it an image that matches no attestor is simply not verified, so anything unsigned sails through. mutateDigest: true closes a genuine time-of-check-to-time-of-use hole — Kyverno verifies a tag, resolves it to a digest, and admits the pod pinned to that digest, so nobody can repoint the mutable tag between admission and image pull. And failurePolicy: Fail is the fail-closed choice: with Ignore, a Kyverno outage becomes an open door, which is exactly when someone will notice. Fail-closed has a cost — a broken admission controller now blocks pod creation — so keep the match tight (named namespaces, never kube-system) and run Kyverno with more than one replica. Keyless verification removes the key-rotation problem entirely by binding trust to the CI identity in the certificate, which is why it is the direction most platforms are moving. Note the enforcement field moved to a per-rule failureAction in newer Kyverno releases — check kubectl explain clusterpolicy.spec on the cluster you are given. Background in Sigstore & cosign and supply-chain scanning.
First, enforcing before measuring. Both tasks above have a measure step — dryrun for Gatekeeper, Audit plus policy reports for Kyverno — and skipping it is how a policy rollout becomes an incident. Second, testing a policy as yourself. You are almost certainly cluster-admin on your practice cluster, and admin bypasses more than you expect: Gatekeeper and Kyverno both exclude system namespaces and their own service accounts by default. Verify from the identity that will actually be affected — --as for RBAC, a real tenant namespace for admission — or you are testing the wrong thing and will believe the wrong answer.
Foxy: Third mock. 68. Down four points from Set 2 — I have got worse.
Timmy: Or the paper got harder, which it did. What did you lose them on?
Foxy: The Gatekeeper one. I wrote the whole ConstraintTemplate, it was right, and it found nothing. Eight points, gone.
Benny: Because a referential policy cannot see what Gatekeeper has not replicated. One Config object, four lines. That is not a knowledge gap, Foxy — that is one fact.
Gizmo: Or skip the whole policy thing! Just ask people nicely not to reuse hostnames. Trust-based platform engineering. 🤑
Professor Owl: We tried that, Gizmo. It is called “last month,” and it cost six hours of payments.
Dot: Can I say the quiet thing? I liked the tasks where the fix was tiny and the diagnosis was the whole job. That is genuinely what my week looks like.
Timmy: Then you are ready, Dot. Foxy, add “Config before referential policy” to your one-page notes and sit Set 4 in a fortnight. A four-point dip on a harder paper is a pass trending upward.
Score yourself
☺ Like you’re 10: Add up your points, turn it into a percentage, then look at which questions you lost — that second part is the useful part.
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.”
| Task | Domain | Points | Your score |
|---|---|---|---|
| X1 · AppProject fence for a tenant | GitOps | 4 | |
| X2 · App-of-Apps cluster bootstrap | GitOps | 5 | |
| X3 · Flux image automation writing back to Git | GitOps | 6 | |
| X4 · CronWorkflow, retries, exit handler, GC | GitOps | 5 | |
| X5 · Flagger gate plus a custom MetricTemplate | GitOps | 5 | |
| X6 · CRD conversion webhook with CA injection | Platform APIs | 5 | |
| X7 · Crossplane go-templating plus auto-ready | Platform APIs | 6 | |
| X8 · Backstage catalog registration and relations | Platform APIs | 5 | |
| X9 · Server-side apply field ownership | Platform APIs | 4 | |
| X10 · kubebuilder markers and generated CRDs | Platform APIs | 5 | |
| X11 · LogQL queries and a Loki ruler alert | Observability | 7 | |
| X12 · spanmetrics connector and k8sattributes | Observability | 7 | |
| X13 · Triage an alert that never paged | Observability | 6 | |
| X14 · Triage a PVC that will not bind | Architecture | 5 | |
| X15 · Triage preemption that finds no victims | Architecture | 5 | |
| X16 · cert-manager issuer chain and CA injection | Architecture | 5 | |
| X17 · Referential Gatekeeper constraint | Security | 8 | |
| X18 · cosign verification at admission | Security | 7 | |
| Total | All five domains | 100 |
Computing your result. The points total 100, so your raw score is your percentage, and the pass mark 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 made of five even domains and a 70 made of four strong domains plus a zero are completely different results, and the second fails a real exam the first day the task draw is unkind.
| Domain | Available | Yours | If you scored under two-thirds, go here |
|---|---|---|---|
| GitOps & Continuous Delivery | 25 | GitOps workflows, CI/CD & progressive delivery, then re-drill the GitOps bank. | |
| Platform APIs & Self-Service | 25 | Platform APIs & operators, self-service, then the Platform APIs bank. | |
| Observability & Operations | 20 | Observability, reliability & incidents, then the Observability bank. | |
| Platform Architecture & Infrastructure | 15 | Platform architecture, storage & state, scaling, then the Architecture bank. | |
| Security & Policy Enforcement | 15 | Security & policy, governance, then the Security bank. |
What to do with each individual miss. Sort your zeros and halves into three piles, because they need three different treatments. A task you did not recognise at all is a knowledge gap: read the lesson linked in its solution, then re-do the task cold two days later. A task where you knew the shape but burned the clock hunting field names is a speed problem, and the fix is the command reference plus kubectl explain drills, not more reading. A task that broke in a way you could not diagnose is a triage gap: work the troubleshooting playbook, triaging workloads and triaging delivery until the evidence order is automatic. Set 3 is deliberately heavy on that third pile — five of its eighteen tasks are diagnosis rather than construction.
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 — and expect Set 3 to come in a few points below Set 2 for the same person, because it is a harder paper by design.
| Score | Read it as | Next move |
|---|---|---|
| 80–100 | Comfortably ready, with margin for a bad task draw — on the hardest of the three papers. | Stop grinding tasks. Work the final checklist, skim the field notes for exam-day logistics, and book it. |
| 64–79 | A pass on this paper, with no margin if two tasks go sideways. | Re-drill only your weakest domain, then sit a timed paper again. Speed work, not new topics. |
| 50–63 | Close. 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 50 | Genuine 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 rather than knowledge — 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, and start from the bottom so Security is fresh rather than rushed.
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 does an empty clusterResourceWhitelist mean, and at what point is the restriction evaluated? 3. Which of the three Flux image-automation objects writes anything, and where does it write? 4. Name the four reasons the scheduler declines to preempt for a high-priority pending pod. 5. In LogQL, why must the stream selector be tight, and what does unwrap do? 6. What single Gatekeeper object must exist before a referential constraint can find anything, and what is the failure mode if it is missing? 7. In a Kyverno verifyImages rule, what do required: true and mutateDigest: true each protect you from?
Check your answers
- kubernetes.io/docs, kubernetes.io/blog, task-specific docs linked from the exam’s Quick Reference box, and the local
man//usr/sharedocumentation 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. - An empty list means no cluster-scoped resources at all are permitted — it is a deny, not an absence of opinion. Project restrictions are evaluated at sync time against the rendered manifests, so a violation appears as a failed sync rather than a rejected commit.
- Only the
ImageUpdateAutomationwrites, and it writes to Git, not to the cluster.ImageRepositoryscans the registry and caches tags;ImagePolicyselects one tag and publishes it asstatus.latestImage. The cluster changes afterwards because the ordinaryKustomizationreconciles the new commit. - The incoming pod’s class sets
preemptionPolicy: Never; no victim set on any single node would make it fit; every candidate victim has equal or higher priority (ties are never preempted); or the only viable victim sets would violate a PodDisruptionBudget — which the scheduler respects on a best-effort basis.status.nominatedNodeNameis the tell that preemption already succeeded and you are waiting for termination. - The stream selector in braces is the only indexed part of the query, so a loose selector scans every chunk in the range.
unwrapturns an extracted label into a numeric sample value, which is what letsquantile_over_time,sum_over_timeand the other unwrapped range aggregations work on values rather than counting lines. - The
Configresource ingatekeeper-system, with the kind listed underspec.sync.syncOnly— that is what replicates objects intodata.inventory. Missing it, the policy loads cleanly, admits everything, and reports zero violations: it fails silently, which is why you roll out indryrunand read the violation count before enforcing. required: truerejects images that match no attestor, instead of quietly skipping verification — the difference between a control and a decoration.mutateDigest: truepins the admitted pod to the digest that was actually verified, closing the time-of-check-to-time-of-use gap where a mutable tag is repointed between admission and image pull.
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. 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. Go again with Set 1 or Set 2, drill a single domain in the bank, or head back to the course home to pick a lesson.