Mock Exam · Set 6
This is the hardest paper on the site, and it is meant to be your final dress rehearsal — the one you sit a few days before the real thing, when you already know the material and what you need is a stress test rather than a lesson. Eighteen all-new tasks, weighted exactly to the CNPE blueprint, in one unbroken 120-minute block. What makes Set 6 harder than Set 2 is not obscurity — nothing here is off-blueprint — it is chaining. Most tasks join two capabilities together: a Rollout whose analysis needs a Prometheus rule that does not exist yet, a Crossplane claim that has to survive a Kyverno gate, a Backstage golden path backed by an admission policy that enforces the same rule server-side. And seven of the eighteen hand you something that is already broken: you cannot finish the task until you have diagnosed it from evidence. Every task has an objective done when check, a point value, and a worked solution folded away so you can sit it honestly and mark it afterwards. Total the sheet, compare against 64%, and let the misses choose your last few days.
Up to now the tests have been like being asked to tie a knot, then separately asked to climb a rope. Today someone hands you a rope that is already tangled, on a windy day, and says: untangle it, tie the knot, then climb. Same skills — but now they arrive joined together and slightly wrong, which is exactly how real problems show up. You will be slower than you were on the drills. That is not a sign you have got worse; it is the whole point of a dress rehearsal.
Before you start — exam conditions
☺ Like you’re 10: A rehearsal only helps if you run it like the real show — same clock, same rules, no help from the wings.
Sitting a hard paper comfortably is worse than not sitting it at all: you get a flattering number and no diagnosis. Set the conditions before you start the timer, not after you hit the first task that hurts.
One — build the cluster the night before, and check it twice. A throwaway kind or minikube cluster is enough, but Set 6 touches more components than the earlier papers. You want Argo CD, Argo Rollouts, Flux (including the image-reflector and image-automation controllers, which the default install leaves out), Tekton Pipelines, the kube-prometheus-stack, an OpenTelemetry Collector, Loki, Crossplane with the patch-and-transform function, Kyverno with policy exceptions enabled, Velero, and a policy-capable CNI. Confirm every one of them is healthy before the timer starts. If something turns out to be missing when you reach its task, skip the task and move on — never stop the clock to install things, because on the day you will not be allowed to.
# --- the night before -------------------------------------------------
kind create cluster --name cnpe-mock6
flux install --components-extra=image-reflector-controller,image-automation-controller
kubectl get pods -A | grep -vE 'Running|Completed' # empty, or it is a task you will skip
# --- ninety seconds before the clock starts ---------------------------
kubectl config use-context kind-cnpe-mock6
kubectl config set-context --current --namespace=default
kubectl config view --minify -o jsonpath='{..namespace}{"\n"}'
alias k=kubectl
export do="--dry-run=client -o yaml"
export now="--grace-period=0 --force"Two — one timer, started once. 120 minutes, no pausing for coffee, a CRD that will not become Established, or a laptop that decides to update itself. If you lose nine minutes to your own machine, that is data rather than an excuse. Sit it at roughly the hour your real exam is booked; your 9 a.m. brain and your 9 p.m. brain are different candidates.
Three — only the permitted documentation, open before you start. The allowlist is narrower than most candidates expect: kubernetes.io/docs, kubernetes.io/blog, any task-specific documentation the exam links from its own Quick Reference box, and the local documentation on the exam desktop — man pages and whatever ships under /usr/share. That is the entire list. Argo CD, Flux, Tekton, Crossplane, Backstage, Prometheus, OpenTelemetry, Kyverno, Gatekeeper, Istio and Helm documentation are not permitted, and neither is this site, your notes, a search engine or an AI assistant.
Four — no copy-paste from the solutions, and no AI assistance of any kind. On a chained task the temptation is worse than usual, because the first half often works and the second half does not. Spend the budget, fail the task, read the solution after the sitting.
For the tools whose own documentation is not allowed, the resource shapes have to be in your head or reachable from what to know cold — you cannot look up an AnalysisTemplate, an ImagePolicy, a ValidatingAdmissionPolicy or a PolicyException mid-exam. What you can always do is interrogate the cluster: kubectl explain <kind> --recursive, kubectl api-resources and kubectl get <kind> <existing> -o yaml are not documentation, they are the live API, and they are your real reference. This matters more on Set 6 than anywhere else, because several tasks use APIs whose version has moved recently — Flux image automation and Kyverno policy exceptions in particular. Check the group/version on the cluster in front of you rather than trusting any page, including this one. Separately: exam specifics move. Task counts, the 120-minute duration, the 64% pass mark, domain weights, the permitted-resource list and the Kubernetes version on the desktop have all changed before and will change again. Everything here is a study aid written at a point in time — the official CNCF certification pages and Linux Foundation training site are the only authority. Confirm before you book.
Five — read the whole paper first, then choose your order. Five minutes spent reading all eighteen briefs is the highest-return five minutes of the sitting, and on this paper it is worth more than usual: the chained tasks are not uniformly expensive, and the two Security tasks at the bottom are worth as much as the whole Architecture block, and more per task than anything else on the paper. Bank the cheap points first. Sequential order is a trap in a weighted, partial-credit exam.
Your time budget
☺ Like you’re 10: Give every question a small share of the two hours, and when a question runs over its share, put a sticky note on it and move on. You can always come back.
Eighteen tasks in 120 minutes averages just under seven minutes each — six, once you subtract the reading and verification time — but on a chained paper the averages lie badly. Budget deliberately: five minutes to read everything, about 108 minutes of task work, and seven minutes at the end to re-run your done-when checks. On Set 6 that final sweep matters more than on any other paper, because a chained task can look finished while its second half silently never fired.
| Block | Tasks | Budget | Running total | Note |
|---|---|---|---|---|
| Read the whole paper | — | 5 min | 5 | Mark each task cheap / medium / expensive, and mark the seven that are already broken. |
| GitOps & Continuous Delivery | X1–X5 | 7 + 5 + 6 + 6 + 6 = 30 min | 35 | X1 is the expensive one and it is first. Do not let position dictate order. |
| Platform APIs & Self-Service | X6–X10 | 7 + 6 + 6 + 6 + 4 = 29 min | 64 | X10 is the shortest task on the paper — four minutes, four points. Take it early. |
| Observability & Operations | X11–X13 | 8 + 8 + 6 = 22 min | 86 | Twenty points across three tasks. X11 is triage and either resolves fast or eats your slack. |
| Platform Architecture & Infrastructure | X14–X16 | 6 + 5 + 5 = 16 min | 102 | Three self-contained tasks — the best points-per-minute on the paper after the Security pair. |
| Security & Policy Enforcement | X17–X18 | 6 + 5 = 11 min | 113 | Two tasks, fifteen points. X17 alone is worth eight. Never leave these unattempted. |
| 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, with a Set 6 amendment. When a task passes roughly six minutes without a working done-when check, stop, write one line about where you stalled, leave whatever you applied in place — partial credit is real — and go to the next task. The amendment for a chained paper: if you have completed the first capability of a chain and the second is fighting you, that is still a flag. Half a chained task is often most of its marks, and a fresh five-pointer elsewhere is always worth more than the last two marks of the one in front of you. Timmy’s version: you are not paid to finish tasks, you are paid to bank points.
Points, not tasks, are the unit of this exam. With a 64% bar you can fail four or five tasks outright and still pass comfortably — but only if you actually attempted the cheap ones. On a chained paper there is a second trap on top of that: the sunk-cost pull of a task where half your work is already applied. That half is banked whether you continue or not. Walk away from it exactly as readily as you would from a task you had not started.
GitOps & Continuous Delivery — X1 to X5 (25 points)
☺ Like you’re 10: These five are about the robot that reads your instructions from a folder — but this time the instructions point at things that do not exist yet, or point at them wrongly, and you have to find out which.
The biggest slice of the blueprint. Two of these five — X1 and X5 — hand you something that is already broken. Background reading lives in GitOps workflows, CI/CD & progressive delivery and release engineering, with tool-level detail in Argo Rollouts, Flux and Tekton — but read those after the sitting, not during it.
X1 · Rescue a canary whose analysis never returns data
The checkout Rollout is delivered by Argo CD and has been stuck at 20% weight for forty minutes. The AnalysisRun shows Inconclusive, then Error, and the platform channel has two engineers arguing about whether to abort. The AnalysisTemplate queries a recorded series called job:checkout_success_rate:ratio_rate5m — and nobody has ever created the recording rule that produces it. Fixing the template alone will not help; you have to build the metric it depends on, in the namespace and with the labels Prometheus actually discovers.
Your task:
- From evidence, establish why the analysis returns no data — do not guess, and do not abort the Rollout.
- Create the
PrometheusRulethat producesjob:checkout_success_rate:ratio_rate5m, labelled so the running Prometheus actually loads it, and confirm the series exists. - Fix the
AnalysisTemplateso an empty result cannot be read as a pass, and pass the service and namespace in as arguments from the Rollout. - Re-trigger the canary and let the analysis complete on its own.
Done when: kubectl -n checkout get analysisrun --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].status.phase}' prints Successful and kubectl argo rollouts status checkout -n checkout prints Healthy. (7 points)
Show the worked solution
# 1. evidence order — the AnalysisRun tells you what it asked and what came back
kubectl -n checkout get analysisrun -o wide
kubectl -n checkout describe analysisrun $(kubectl -n checkout get analysisrun \
--sort-by=.metadata.creationTimestamp -o name | tail -1)
# look at status.metricResults[].measurements[].message
# 2. ask Prometheus the same question yourself
kubectl -n monitoring port-forward svc/kube-prometheus-stack-prometheus 9090:9090 &
curl -sG http://localhost:9090/api/v1/query \
--data-urlencode 'query=job:checkout_success_rate:ratio_rate5m'
# {"status":"success","data":{"resultType":"vector","result":[]}} <- empty: the rule does not exist
# 3. which rules did Prometheus actually load, and which does it select?
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.ruleSelector}{"\n"}'
curl -s http://localhost:9090/api/v1/rules | grep -o 'checkout' | headapiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: checkout-slo
namespace: monitoring
labels:
release: kube-prometheus-stack # MUST match spec.ruleSelector on the Prometheus CR
spec:
groups:
- name: checkout.rules
interval: 30s
rules:
- record: job:checkout_success_rate:ratio_rate5m
expr: |
sum by (service, namespace) (rate(http_requests_total{job="checkout", code!~"5.."}[5m]))
/
sum by (service, namespace) (rate(http_requests_total{job="checkout"}[5m]))apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: success-rate
namespace: checkout
spec:
args:
- name: service
- name: namespace
metrics:
- name: success-rate
initialDelay: 60s # let the 5m rate window fill before judging
interval: 30s
count: 4
failureLimit: 1
successCondition: len(result) > 0 && result[0] >= 0.95
provider:
prometheus:
address: http://prometheus-operated.monitoring.svc:9090
query: |
job:checkout_success_rate:ratio_rate5m{service="{{args.service}}", namespace="{{args.namespace}}"}
---
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: checkout
namespace: checkout
spec:
replicas: 4
selector:
matchLabels:
app: checkout
template:
metadata:
labels:
app: checkout
spec:
containers:
- name: checkout
image: ghcr.io/acme/checkout:3.4.0
ports:
- name: http
containerPort: 8080
strategy:
canary:
steps:
- setWeight: 20
- pause: { duration: 60s }
- analysis:
templates:
- templateName: success-rate
args:
- name: service
value: checkout
- name: namespace
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- setWeight: 60
- pause: { duration: 60s }kubectl apply -f prometheusrule.yaml
sleep 60 # one evaluation interval, then the series exists
curl -sG http://localhost:9090/api/v1/query \
--data-urlencode 'query=job:checkout_success_rate:ratio_rate5m'
kubectl apply -f analysistemplate.yaml -f rollout.yaml
kubectl argo rollouts retry rollout checkout -n checkout
kubectl argo rollouts get rollout checkout -n checkout --watch
kubectl -n checkout get analysisrun --sort-by=.metadata.creationTimestamp \
-o jsonpath='{.items[-1:].status.phase}' # Successful
kubectl argo rollouts status checkout -n checkoutWhy: two separate bugs are stacked, which is what makes this a chain. The first is discovery: a PrometheusRule is only loaded if its labels match the Prometheus CR’s ruleSelector, and the kube-prometheus-stack ships a selector on release. A rule with the wrong label applies cleanly, shows no error anywhere, and simply never evaluates — which is why you check /api/v1/rules rather than trusting kubectl get prometheusrule. The second is the analysis contract: without len(result) > 0, an empty vector makes result[0] blow up and the measurement reports Error; on some expressions an empty result silently evaluates falsy and a broken metric reads as a failing canary, which is worse — it aborts good releases. Guard the emptiness explicitly, and give the rate window time to fill with initialDelay or your first measurement judges a metric that has barely any samples in it. More on analysis shapes in Argo Rollouts and on recording rules in Prometheus.
X2 · Fence a tenant with an AppProject and a sync window
The payments team has their own Argo CD Applications, and last month one of them was pointed at a fork of the config repo and quietly deployed a ClusterRoleBinding into kube-system. Separately, change management has asked that automated syncs stop during the trading day — but a human must still be able to sync in an emergency.
Your task:
- Create an
AppProjectthat permits exactly one source repository, restricts destinations topayments-*namespaces on the in-cluster server, and forbids all cluster-scoped resources. - Add a deny sync window covering 09:00–17:00 Monday to Friday that still allows manual syncs.
- Prove the fence works by creating an Application in that project that violates it.
Done when: argocd proj windows list tenant-payments lists the deny window, and an Application in the project whose destination namespace is kube-system reports a spec error — kubectl -n argocd get app rogue -o jsonpath='{.status.conditions[*].type}' prints InvalidSpecError. (4 points)
Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: tenant-payments
namespace: argocd
spec:
description: Payments tenant — one repo, their own namespaces, no cluster scope.
sourceRepos:
- https://github.com/acme/platform-config.git
destinations:
- server: https://kubernetes.default.svc
namespace: 'payments-*'
clusterResourceWhitelist: [] # empty list = no cluster-scoped kinds at all
namespaceResourceBlacklist:
- group: ''
kind: ResourceQuota
- group: ''
kind: LimitRange
syncWindows:
- kind: deny
schedule: '0 9 * * 1-5'
duration: 8h
timeZone: Europe/London
applications:
- '*'
manualSync: true # a human can still sync during the windowapiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: rogue
namespace: argocd
spec:
project: tenant-payments
source:
repoURL: https://github.com/acme/platform-config.git
targetRevision: main
path: apps/payments
destination:
server: https://kubernetes.default.svc
namespace: kube-system # not matched by payments-* — rejectedkubectl apply -f appproject.yaml
argocd proj get tenant-payments
argocd proj windows list tenant-payments
kubectl apply -f rogue-app.yaml
kubectl -n argocd get app rogue -o jsonpath='{.status.conditions[*].type}'
kubectl -n argocd get app rogue -o jsonpath='{.status.conditions[*].message}'
# application destination is not permitted in project 'tenant-payments'
argocd app sync payments-api # inside the window: blocked unless manual is allowedWhy: an AppProject is Argo CD’s tenancy boundary, and the three lists do different jobs: sourceRepos stops someone deploying from a fork, destinations stops a tenant writing outside their own namespaces, and clusterResourceWhitelist — empty, not absent — stops them creating ClusterRoles, CRDs and webhooks. Leaving the whitelist out entirely is the trap: absent means “inherit the default”, an empty list means “none”. The violation surfaces as an InvalidSpecError condition on the Application rather than a rejected apply, so the fence is only visible if you look at status. Sync windows are the change-management half: manualSync: true keeps the emergency door open, which is what makes a deny window acceptable to an on-call team at all. See Argo CD and governance & compliance.
X3 · Close the loop with Flux image automation writing back to Git
Every patch release of the podinfo platform component currently means a human editing a tag in the config repo. It gets forgotten, and the running version drifts weeks behind the registry. You are automating the whole loop: scan the registry, pick the newest version inside a semver range, and commit the change back to Git so the reconciler does the deployment as usual.
Your task:
- Create an
ImageRepositorythat scans the registry and anImagePolicythat selects the newest6.xrelease. - Mark the image field in the Deployment manifest with the setter comment the automation looks for.
- Create an
ImageUpdateAutomationthat commits and pushes the update to the same branch Flux reconciles from.
Done when: flux get images policy -A shows the policy with a resolved LATEST IMAGE, git log --oneline -1 origin/main shows a commit authored by the automation, and kubectl -n podinfo get deploy podinfo -o jsonpath='{.spec.template.spec.containers[0].image}' matches the policy’s latest image. (5 points)
Show the worked solution
# the two image controllers are NOT part of a default `flux install` flux install --components-extra=image-reflector-controller,image-automation-controller kubectl api-resources | grep image.toolkit # confirm the served version before you write YAML
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
name: podinfo
namespace: flux-system
spec:
image: ghcr.io/stefanprodan/podinfo
interval: 5m
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
name: podinfo
namespace: flux-system
spec:
imageRepositoryRef:
name: podinfo
policy:
semver:
range: 6.x
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata:
name: podinfo
namespace: flux-system
spec:
interval: 5m
sourceRef:
kind: GitRepository
name: flux-system
git:
checkout:
ref:
branch: main
commit:
author:
name: fluxcdbot
email: fluxcdbot@users.noreply.github.com
messageTemplate: |
chore(images): {{range .Changed.Changes}}{{print .OldValue}} -> {{println .NewValue}}{{end}}
push:
branch: main
update:
path: ./apps/podinfo
strategy: Setters# apps/podinfo/deployment.yaml — the marker comment is the whole mechanism
spec:
template:
spec:
containers:
- name: podinfo
image: ghcr.io/stefanprodan/podinfo:6.5.4 # {"$imagepolicy": "flux-system:podinfo"}kubectl apply -f image-automation.yaml
flux get images repository -A
flux get images policy -A # LATEST IMAGE resolves within one interval
flux reconcile image repository podinfo -n flux-system
flux reconcile image update podinfo -n flux-system
git fetch origin && git log --oneline -1 origin/main
kubectl -n podinfo get deploy podinfo -o jsonpath='{.spec.template.spec.containers[0].image}'Why: this is the one piece of GitOps that legitimately writes to the repo rather than only reading from it, and it stays inside the four principles because the commit is still the source of truth — the automation is just a very reliable colleague raising the same one-line change you kept forgetting. Three details decide whether it works. The image controllers are optional components and a default flux install leaves them out, so the CRDs are simply absent and your manifests fail with “no matches for kind”. The Setters strategy does nothing without the inline $imagepolicy marker comment — that comment, not the field path, is what the updater matches on. And the automation’s credentials must be able to push: a read-only deploy key reconciles fine and then fails silently at the push step, visible only in kubectl -n flux-system logs deploy/image-automation-controller. Compare with Argo CD Image Updater in Flux and release engineering.
X4 · Fan a Tekton pipeline out with a matrix, gate it, and always clean up
The library build currently runs its test suite three times in three near-identical Tasks, promotes unconditionally whether the target is staging or production, and leaves its scratch PVC behind whenever anything fails. You are rewriting the Pipeline to fan the tests out from one definition, promote only for production, and clean up on every path including failure.
Your task:
- Replace the three test Tasks with one matrixed
PipelineTaskthat fans out across three runtime versions. - Guard the
promotetask with awhenexpression so it only runs when theenvironmentparameter isprod. - Add a
finallysection that always cleans up, plus a notification that fires only when the pipeline failed.
Done when: for a run with environment=staging, kubectl get taskrun -l tekton.dev/pipelineRun=<run>,tekton.dev/pipelineTask=unit-tests -o name | wc -l prints 3, kubectl get pipelinerun <run> -o jsonpath='{.status.skippedTasks[*].name}' prints promote, and tkn pipelinerun describe <run> shows the cleanup finally task Succeeded. (5 points)
Show the worked solution
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: pytest
spec:
params:
- name: version
type: string
steps:
- name: test
image: python:$(params.version)-alpine
script: |
#!/bin/sh
set -eu
python --version
echo "running the suite on $(params.version)"
---
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: cleanup
spec:
steps:
- name: cleanup
image: alpine:3.20
script: |
#!/bin/sh
echo "removing scratch data"
---
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: verify-and-ship
spec:
params:
- name: environment
type: string
default: staging
tasks:
- name: unit-tests
taskRef:
name: pytest
matrix:
params:
- name: version
value: ["3.11", "3.12", "3.13"] # one PipelineTask, three TaskRuns
- name: promote
runAfter: [unit-tests]
when:
- input: $(params.environment)
operator: in
values: ["prod"]
taskRef:
name: cleanup # stand-in for the real promote Task
finally:
- name: cleanup
taskRef:
name: cleanup
- name: notify-on-failure
when:
- input: $(tasks.status)
operator: in
values: ["Failed"]
taskRef:
name: cleanupkubectl apply -f pipeline.yaml
tkn pipeline start verify-and-ship --param environment=staging --showlog
RUN=$(kubectl get pipelinerun --sort-by=.metadata.creationTimestamp -o name | tail -1)
NAME=${RUN#pipelinerun.tekton.dev/}
# scope the label selector to THIS run, or a second sitting counts six TaskRuns
kubectl get taskrun -l tekton.dev/pipelineRun=$NAME,tekton.dev/pipelineTask=unit-tests \
-o name | wc -l # 3
kubectl get $RUN -o jsonpath='{.status.skippedTasks[*].name}' # promote
kubectl get $RUN -o jsonpath='{.status.skippedTasks[*].reason}'
# When Expressions evaluated to false
tkn pipelinerun describe $NAMEWhy: the three features cover three different axes and the exam likes them together. Matrix is fan-out: the parameter listed under matrix.params is not also listed under params, and Tekton produces the cartesian product as separate TaskRuns you can find by the tekton.dev/pipelineTask label. when is conditional execution, and the important consequence is that a skipped task’s dependents are skipped too — which is why a when on a mid-pipeline task can quietly prune half your graph. finally runs regardless of outcome, which is where cleanup belongs; inside it, $(tasks.status) gives you the aggregate result (Succeeded, Completed, Failed, None) so a notification can fire on failures only. Note that a skipped task counts as neither success nor failure, so a pipeline whose only real work was skipped still reports Completed. More in Tekton.
X5 · Teach Argo CD what “healthy” means for your own API
Your platform ships an Environment custom resource, and an App-of-Apps delivers it in sync wave 0 with the tenant’s workloads in wave 1. The problem: Argo CD reports the parent Application Healthy the instant the Environment object is created, so wave 1 starts while the environment is still provisioning and the workloads crash-loop against a database that does not exist yet. Nobody has changed a manifest — Argo CD simply has no opinion about a kind it has never seen.
Your task:
- Confirm from evidence that Argo CD is assessing the
Environmentas healthy without evaluating anything. - Add a custom Lua health check for
platform.acme.io/Environmentthat maps itsReadycondition onto Progressing, Healthy and Degraded. - Test the script against a real object before you rely on it, then apply it and show the parent Application follows the condition.
Done when: with the Environment’s Ready condition False, kubectl -n argocd get app platform -o jsonpath='{.status.health.status}' prints Degraded; once the controller sets Ready=True the same command prints Healthy and the wave-1 Application begins syncing. (4 points)
Show the worked solution
# 1. evidence: the resource tree shows Healthy with no message at all
argocd app get platform --show-operation
kubectl -n argocd get app platform -o jsonpath='{range .status.resources[*]}{.kind}{" "}{.health.status}{"\n"}{end}'
# Environment Healthy <- with no health check registered, unknown kinds default to HealthyapiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cm
namespace: argocd
labels:
app.kubernetes.io/name: argocd-cm
app.kubernetes.io/part-of: argocd
data:
resource.customizations.health.platform.acme.io_Environment: |
local hs = {}
hs.status = "Progressing"
hs.message = "waiting for the Environment controller"
if obj.status ~= nil and obj.status.conditions ~= nil then
for i, condition in ipairs(obj.status.conditions) do
if condition.type == "Ready" and condition.status == "False" then
hs.status = "Degraded"
hs.message = condition.message
return hs
end
if condition.type == "Ready" and condition.status == "True" then
hs.status = "Healthy"
hs.message = condition.message
return hs
end
end
end
return hs# 2. test the Lua against a real object BEFORE trusting it in a sync wave
kubectl -n demo get environment demo -o yaml > /tmp/env.yaml
argocd admin settings resource-overrides health /tmp/env.yaml --argocd-cm-path ./argocd-cm.yaml
# merge, never plain apply: argocd-cm holds url, dex and exclusions too, and a
# partial apply would drop every key you left out of your file
kubectl -n argocd patch cm argocd-cm --type merge --patch-file argocd-cm.yaml
kubectl -n argocd rollout restart deploy/argocd-server statefulset/argocd-application-controller
kubectl -n argocd get app platform -o jsonpath='{.status.health.status}' # Degraded
kubectl -n demo patch environment demo --subresource=status --type=merge \
-p '{"status":{"conditions":[{"type":"Ready","status":"True","reason":"Provisioned","message":"ready","lastTransitionTime":"2026-07-21T09:00:00Z"}]}}'
kubectl -n argocd get app platform -o jsonpath='{.status.health.status}' # HealthyWhy: Argo CD ships built-in health assessments for the workload kinds it knows and, for everything else, reports Healthy by default — a deliberate choice that keeps unknown CRs from blocking syncs forever, and a landmine the moment you use sync waves. Waves advance when the current wave is healthy, so a kind with no opinion makes the wave boundary meaningless, which is exactly the failure in front of you. The fix is a Lua script registered under the key resource.customizations.health.<group>_<kind> — note the underscore between group and kind — returning a table with status and message. Two habits matter: return Progressing rather than Healthy as the fallback, so a missing status blocks the wave instead of waving it through; and test the script with argocd admin settings resource-overrides health against a real object first, because a Lua error is reported as Unknown and is painful to debug through a controller restart. Background in Argo CD and platform APIs & operators.
“Three of those five were somebody else’s half-finished work, and that is exactly what my week looks like. The canary that never decides, the app that says Healthy while my pods are on fire, the tag nobody remembered to bump — I have filed tickets for all three. What I notice is that none of the fixes were clever. They were all ‘the thing you configured is not the thing that is running, go and look at what is actually loaded’.”
Platform APIs & Self-Service — X6 to X10 (25 points)
☺ Like you’re 10: These five are about inventing your own kind of Kubernetes object — and then making sure nobody can use it wrongly, that it cleans up after itself, and that two robots do not fight over it.
The other 25% domain, and the one where Set 6 leans hardest on chaining: a custom API is only useful when the guardrails, the permissions and the quotas around it work too. Background: Platform APIs & operators, self-service & golden paths, IaC & control planes and developer experience.
X6 · Publish a claimable API and gate it with policy
Teams have been asking for self-service Postgres. You have a working Crossplane Composition already; what is missing is the public API and the rules around it. Finance wants every database attributable to a cost centre, and the DBAs want the large size to require a named approver — but neither wants to review tickets, so both rules have to be enforced at the moment of the claim.
Your task:
- Publish a
CompositeResourceDefinitionexposing a namespacedPostgresInstanceclaim with a requiredsize(enum) andteam. - Write a Kyverno
ClusterPolicythat rejects a claim without aplatform.acme.io/cost-centreannotation, and rejectssize: largeunless an approver label is present. - Show one rejected claim and one accepted claim that goes on to compose real resources.
Done when: kubectl -n team-a apply -f claim-bad.yaml fails with the policy message, kubectl -n team-a apply -f claim-good.yaml succeeds, and kubectl -n team-a get postgresinstance demo -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' eventually prints True with kubectl get managed listing the composed resource. (6 points)
Show the worked solution
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresinstances.platform.acme.io
spec:
group: platform.acme.io
names:
kind: XPostgresInstance
plural: xpostgresinstances
claimNames:
kind: PostgresInstance
plural: postgresinstances
defaultCompositionRef:
name: postgres-standard
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [parameters]
properties:
parameters:
type: object
required: [size, team]
properties:
size:
type: string
enum: [small, medium, large]
team:
type: string
pattern: '^[a-z][a-z0-9-]{1,29}$'
additionalPrinterColumns:
- name: Size
type: string
jsonPath: .spec.parameters.size
- name: Team
type: string
jsonPath: .spec.parameters.teamapiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: postgres-claim-guardrails
spec:
validationFailureAction: Enforce # per-rule validate.failureAction in newer Kyverno
background: false
rules:
- name: require-cost-centre
match:
any:
- resources:
kinds:
- platform.acme.io/v1alpha1/PostgresInstance
validate:
message: "a PostgresInstance needs a platform.acme.io/cost-centre annotation like cc-4021"
pattern:
metadata:
annotations:
platform.acme.io/cost-centre: "cc-?*"
- name: large-needs-approval
match:
any:
- resources:
kinds:
- platform.acme.io/v1alpha1/PostgresInstance
preconditions:
all:
- key: "{{ request.object.spec.parameters.size }}"
operator: Equals
value: large
validate:
message: "size=large requires a platform.acme.io/approved-by label naming the DBA who signed it off"
pattern:
metadata:
labels:
platform.acme.io/approved-by: "?*"# claim-bad.yaml — no cost centre, and large without an approver
apiVersion: platform.acme.io/v1alpha1
kind: PostgresInstance
metadata:
name: demo
namespace: team-a
spec:
parameters:
size: large
team: team-a
---
# claim-good.yaml
apiVersion: platform.acme.io/v1alpha1
kind: PostgresInstance
metadata:
name: demo
namespace: team-a
annotations:
platform.acme.io/cost-centre: cc-4021
labels:
platform.acme.io/approved-by: r.okonjo
spec:
parameters:
size: large
team: team-a
writeConnectionSecretToRef:
name: demo-postgreskubectl apply -f xrd.yaml kubectl get xrd xpostgresinstances.platform.acme.io # ESTABLISHED and OFFERED both True kubectl api-resources | grep postgresinstances # the claim CRD now exists kubectl apply -f policy.yaml kubectl get clusterpolicy postgres-claim-guardrails # READY True before you test kubectl -n team-a apply -f claim-bad.yaml # admission webhook denies it kubectl -n team-a apply -f claim-good.yaml kubectl -n team-a get postgresinstance demo kubectl get xpostgresinstance,managed kubectl -n team-a describe postgresinstance demo # events explain a stuck composition
Why: the XRD and the policy answer two different questions and you need both. The XRD’s OpenAPI schema enforces shape — required fields, an enum, a name pattern — and the API server rejects anything malformed before Crossplane ever sees it. But schema cannot express “must reference something that exists elsewhere” or “this value needs a human’s approval”, which is where an admission policy engine belongs. Two mechanics decide whether this works: Kyverno matches custom kinds by fully qualified group/version/Kind, so a bare PostgresInstance can silently match nothing when another API has the same kind name; and preconditions are what let a single policy apply a rule to only some objects, evaluated before the pattern so the cheap common case is not penalised. Remember the claim is namespaced while the composite it creates is cluster-scoped — policy on the claim is policy where the developer actually is. See Crossplane, Kyverno and self-service & golden paths.
X7 · Back a Backstage golden path with an admission policy that agrees with it
Your Backstage template scaffolds new services and stamps a cost-centre label into every pod template. It works — for services created through the template. Roughly a third of the fleet was created before the template existed or by someone running kubectl create deployment, and the FinOps report has a large “unattributed” bucket. The template is a convention; you are going to make the cluster enforce the same rule so the two cannot drift apart.
Your task:
- Give the Backstage
Templatea requiredcostCentreparameter constrained by a regular expression, and have the scaffolder pass it into the skeleton. - Write a
ValidatingAdmissionPolicywhose CEL expression enforces exactly the same regular expression on every Deployment pod template, bound only to tenant namespaces. - Roll the binding out in warn mode first, then switch it to deny, and prove both outcomes.
Done when: kubectl -n tenant-a create deployment nope --image=nginx is rejected quoting the policy message, while a Deployment carrying platform.acme.io/cost-centre: cc-4021 in its pod template is admitted, and the same ^cc-[0-9]{4}$ pattern appears in the template’s parameter schema. (6 points)
Show the worked solution
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: golden-service
title: Golden path service
description: A service that is attributable, observable and admissible on day one.
tags: [recommended, golden-path]
spec:
owner: group:platform
type: service
parameters:
- title: Service details
required: [name, costCentre, owner]
properties:
name:
title: Name
type: string
pattern: '^[a-z][a-z0-9-]{2,29}$'
costCentre:
title: Cost centre
type: string
description: The finance code this service is billed to.
pattern: '^cc-[0-9]{4}$'
owner:
title: Owner
type: string
ui:field: OwnerPicker
ui:options:
catalogFilter:
kind: Group
steps:
- id: fetch
name: Fetch skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
costCentre: ${{ parameters.costCentre }}
owner: ${{ parameters.owner }}
- id: publish
name: Publish the repository
action: publish:github
input:
repoUrl: github.com?owner=acme&repo=${{ parameters.name }}
description: Scaffolded by the golden path
- id: register
name: Register in the catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: ${{ steps.publish.output.remoteUrl }}
- title: Open in the catalog
icon: catalog
entityRef: ${{ steps.register.output.entityRef }}apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-cost-centre
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]
validations:
- expression: |
has(object.spec.template.metadata) &&
has(object.spec.template.metadata.labels) &&
'platform.acme.io/cost-centre' in object.spec.template.metadata.labels &&
object.spec.template.metadata.labels['platform.acme.io/cost-centre'].matches('^cc-[0-9]{4}$')
message: "every pod template needs a platform.acme.io/cost-centre label of the form cc-NNNN — scaffold from the golden path template"
reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-cost-centre
spec:
policyName: require-cost-centre
validationActions: [Deny, Audit] # start with [Warn, Audit], then switch
matchResources:
namespaceSelector:
matchLabels:
platform.acme.io/tenant: "true"kubectl label ns tenant-a platform.acme.io/tenant=true
kubectl apply -f vap.yaml
# measure before you enforce: Warn returns the message on the client without rejecting
kubectl patch validatingadmissionpolicybinding require-cost-centre --type=merge \
-p '{"spec":{"validationActions":["Warn","Audit"]}}'
kubectl -n tenant-a create deployment probe --image=nginx # created, with a warning
kubectl patch validatingadmissionpolicybinding require-cost-centre --type=merge \
-p '{"spec":{"validationActions":["Deny","Audit"]}}'
kubectl -n tenant-a create deployment nope --image=nginx # rejected
kubectl -n tenant-a apply -f good-deployment.yaml # admitted
grep -n 'cc-\[0-9\]' template.yaml # same regex in both placesWhy: a golden path that is only a convention decays the moment someone is in a hurry, so the durable pattern is template plus gate: the template makes the compliant thing easy, and the gate makes the non-compliant thing impossible. ValidatingAdmissionPolicy is the right gate here because it is built into the API server — CEL evaluated in-process, no webhook to deploy, no extra failure mode when your policy controller is down, and available on a cluster where Kyverno or Gatekeeper is not installed. Three details earn the marks: has() guards must come before any map index or CEL errors on a missing field and failurePolicy: Fail then rejects everything; the Binding is a separate object, so the same policy can be warned in one set of namespaces and denied in another; and rolling out through Warn first is the same measure-then-enforce discipline as Pod Security’s server-side dry run. Keep the regular expression identical in template and policy — two nearly-identical rules is worse than one. See Backstage, security & policy and FinOps.
X8 · Settle a server-side apply fight over one field
Your platform CLI applies a standard resource block to every managed Deployment. On shop/web it fails: Apply failed with 1 conflict: conflict with "argocd-controller". An engineer has already run it with --force-conflicts twice, which fixed the resources block and simultaneously stole spec.replicas from the autoscaler, causing a 3 a.m. capacity incident. You need the resources field and only the resources field.
Your task:
- Determine, from the object itself, which field manager currently owns
spec.replicasand which owns the container resources block. - Take ownership of the resources block for the manager
platform-cliwithout disturbing ownership ofspec.replicas. - Prove afterwards that a plain server-side apply from
platform-cliproduces no conflict, and that replicas is still owned by its original manager.
Done when: kubectl -n shop get deploy web --show-managed-fields -o jsonpath='{.metadata.managedFields[*].manager}' lists both managers, kubectl -n shop apply --server-side --field-manager=platform-cli --dry-run=server -f web-resources.yaml exits 0 with no conflict, and the manager entry containing f:replicas is not platform-cli. (5 points)
Show the worked solution
# 1. managedFields is hidden by default — you have to ask for it
kubectl -n shop get deploy web --show-managed-fields -o yaml | head -60
kubectl -n shop get deploy web --show-managed-fields \
-o jsonpath='{range .metadata.managedFields[*]}{.manager}{"\t"}{.operation}{"\t"}{.subresource}{"\n"}{end}'
# who owns replicas, specifically?
kubectl -n shop get deploy web --show-managed-fields -o json \
| grep -o '"manager":"[^"]*"\|f:replicas'# web-resources.yaml — apply ONLY the fields you intend to own.
# Anything you include, you claim; anything you omit later, you release.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: shop
spec:
template:
spec:
containers:
- name: web
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
memory: 512Mi# 2. this conflicts, and the error names the owner and the exact path
kubectl -n shop apply --server-side --field-manager=platform-cli -f web-resources.yaml
# take ONLY these fields — the file has no replicas in it, so nothing else moves
kubectl -n shop apply --server-side --force-conflicts \
--field-manager=platform-cli -f web-resources.yaml
# 3. prove it: no conflict on a repeat, and replicas still belongs to someone else
kubectl -n shop apply --server-side --field-manager=platform-cli \
--dry-run=server -f web-resources.yaml && echo "no conflict"
kubectl -n shop get deploy web --show-managed-fields \
-o jsonpath='{range .metadata.managedFields[*]}{.manager}{" -> "}{.fieldsV1}{"\n"}{end}' | grep replicasWhy: server-side apply moves the merge from your laptop into the API server and records, per field, which manager owns it. That turns “two controllers fighting” from a mystery into a readable list. The rule that carries you through every SSA question is: a manager owns exactly the fields present in its last apply. So the fix is not a cleverer flag, it is a smaller file — an apply document containing only the resources block can never take spec.replicas, whatever --force-conflicts does. That is the mistake in the story: --force-conflicts is safe when the applied document is scoped and catastrophic when someone forces a full manifest that happens to include a field the autoscaler owns. Two more things worth knowing: dropping a field from a subsequent apply releases it, which is how you hand replicas back deliberately; and --show-managed-fields exists because managedFields was hidden from normal output in Kubernetes 1.21 for being unreadable, not because it stopped mattering. See configuration management and the Kubernetes substrate.
X9 · Find out why a custom resource is accepted and then ignored
Developers create Environment objects and nothing happens. kubectl get environments lists them, the CRD is Established, the controller Deployment is 1/1 Running with no restarts, and its logs look busy. No ConfigMap is ever created, no status is ever written, and kubectl wait --for=condition=Ready times out every time. The controller was working last week, before the platform team tightened RBAC across the cluster.
Your task:
- Establish from evidence exactly which verbs on which resources the controller is being denied — do not simply grant it more.
- Restore the minimum permissions the controller needs, including the ones it needs for status and finalizers.
- Show a fresh
Environmentreconciling end to end.
Done when: kubectl auth can-i create configmaps --as=system:serviceaccount:platform-system:env-controller -n demo prints yes, and kubectl -n demo wait --for=condition=Ready environment/demo2 --timeout=90s prints condition met. (4 points)
Show the worked solution
# 1. the logs already contain the answer — grep for the shape, not the symptom kubectl -n platform-system logs deploy/env-controller --tail=200 | grep -i forbidden # configmaps is forbidden: User "system:serviceaccount:platform-system:env-controller" # cannot create resource "configmaps" in API group "" in the namespace "demo" # 2. confirm it from the caller's identity rather than your own SA=system:serviceaccount:platform-system:env-controller kubectl auth can-i --list --as=$SA -n demo kubectl auth can-i create configmaps --as=$SA -n demo # no kubectl auth can-i patch environments/status --as=$SA -n demo # no kubectl auth can-i update environments --as=$SA -n demo # no <- finalizers need this
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: platform:env-controller
rules:
# the resource it owns
- apiGroups: ["platform.acme.io"]
resources: ["environments"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["platform.acme.io"]
resources: ["environments/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["platform.acme.io"]
resources: ["environments/finalizers"]
verbs: ["update"]
# the children it creates
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# so it can report what it did
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform:env-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: platform:env-controller
subjects:
- kind: ServiceAccount
name: env-controller
namespace: platform-systemkubectl apply -f controller-rbac.yaml kubectl -n platform-system rollout restart deploy/env-controller kubectl auth can-i create configmaps --as=$SA -n demo # yes kubectl -n demo apply -f environment-demo2.yaml kubectl -n demo wait --for=condition=Ready environment/demo2 --timeout=90s kubectl -n demo get cm # the child appears kubectl -n demo get events --field-selector involvedObject.kind=Environment
Why: an operator with insufficient RBAC is the quietest failure mode in platform engineering, because every surface you would normally check looks fine: the CRD is valid, the object is stored, the pod is running, and the controller is genuinely working — it is retrying a call the API server keeps refusing. The evidence order is therefore fixed: controller logs first, then kubectl auth can-i --list --as= from the controller’s identity, never your own. The three easily-missed grants are the ones this task is really about. <resource>/status is a separate subresource with its own verbs, so a controller can be allowed to update the object and still be unable to write a condition. <resource>/finalizers needs update whenever the controller adds or removes a finalizer. And events needs create and patch, or the controller loses its only way to tell a developer what went wrong. Grant those four blocks and nothing else — the temptation to reach for a wildcard here is exactly how the cluster-admin bindings in X18 got created. See Kubebuilder and the troubleshooting playbook.
X10 · Cap how much of your own API a tenant can consume
A team automated their environment creation and produced 340 Worker objects overnight, each one spawning pods. CPU quota eventually stopped the damage, but only after the control plane had spent an hour reconciling. You want a limit on the objects themselves, not just on what they consume.
Your task:
- Create a
ResourceQuotainteam-athat caps custom resource counts as well as a couple of built-in object counts. - Demonstrate the cap by creating one object past the limit.
Done when: kubectl -n team-a describe quota platform-objects shows the used and hard counts for the custom resources, and creating the sixth Worker fails with exceeded quota: platform-objects. (4 points)
Show the worked solution
apiVersion: v1
kind: ResourceQuota
metadata:
name: platform-objects
namespace: team-a
spec:
hard:
count/workers.platform.acme.io: "5"
count/postgresinstances.platform.acme.io: "2"
count/services.loadbalancers: "1"
count/secrets: "25"
count/configmaps: "40"kubectl apply -f quota.yaml kubectl -n team-a describe quota platform-objects for i in 1 2 3 4 5 6; do kubectl -n team-a create -f - <<EOF apiVersion: platform.acme.io/v1 kind: Worker metadata: name: etl-$i spec: queue: etl EOF done # the sixth: Error from server (Forbidden): error when creating "STDIN": # workers.platform.acme.io "etl-6" is forbidden: exceeded quota: platform-objects, # requested: count/workers.platform.acme.io=1, used: 5, limited: 5 kubectl -n team-a get resourcequota platform-objects -o yaml | grep -A6 'used:'
Why: ResourceQuota is usually taught as a CPU-and-memory tool, but the generic object-count syntax count/<plural>.<group> works for any namespaced resource including your own custom kinds, and it is the only lever that protects the control plane rather than the nodes. That distinction matters: compute quota stops the pods, but ten thousand custom objects still cost you etcd storage, watch traffic and a controller queue that never drains — damage that a CPU limit does nothing about. Two syntax details are worth memorising because you cannot look them up: the group is appended after a dot and the resource name is the plural as it appears in kubectl api-resources; and a handful of built-ins have special forms such as count/services.loadbalancers and count/services.nodeports, which cap the expensive kinds of Service specifically. Quota is enforced at admission, so it stops the create rather than cleaning up afterwards. Pair it with a LimitRange for per-object defaults — see governance & compliance and platform architecture.
Before you sit this paper, do a pure-speed warm-up on the shapes Set 6 assumes you already own. From a cold terminal, with only kubectl explain --recursive and kubectl api-resources, produce a valid skeleton for each of these in under three minutes apiece: a ValidatingAdmissionPolicy with one CEL expression and its binding; a ResourceQuota counting a custom kind; a ClusterRole granting a controller its status and finalizers subresources; and a PrometheusRule with one recording rule. If any of the four takes longer than three minutes, that is your highest-value study target this week — ahead of any concept, and ahead of reading this page again.
Observability & Operations — X11 to X13 (20 points)
☺ Like you’re 10: These three are about noticing things: making sure the watcher is actually watching, turning traces into numbers, and turning log lines into an alarm.
Twenty percent of the paper across three expensive tasks, and the domain that most rewards a fixed evidence order. When something is “not showing up in Prometheus”, resist the urge to edit YAML — go and read what the system actually loaded. Background: observability, Prometheus, OpenTelemetry, Loki, reliability & incidents and the troubleshooting playbook.
X11 · Work out why a ServiceMonitor is scraping nothing
The payments team added a ServiceMonitor a week ago. Their /metrics endpoint works — you can curl it from inside the cluster — but no payments target ever appears in Prometheus, the dashboard is empty, and their SLO alert has never fired, including during a real incident. Nothing in any log mentions it. Someone has already tried deleting and re-applying the ServiceMonitor three times.
Your task:
- Work from evidence to find every reason the target is missing — there is more than one, and guessing will cost you the task.
- Fix them with the smallest correct changes, on the right objects.
- Prove the target is up and that a query against it returns data.
Done when: curl -s 'http://localhost:9090/api/v1/targets?state=active' | grep -c '"job":"api"' returns at least 1 with "health":"up", and curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=up{job="api"}' returns a result whose value is 1. (8 points)
Show the worked solution
# fixed evidence order. Step one is ALWAYS "what did Prometheus actually load?"
kubectl -n monitoring get secret prometheus-kube-prometheus-stack-prometheus \
-o jsonpath='{.data.prometheus\.yaml\.gz}' | base64 -d | gunzip | grep -A5 payments
# nothing -> the ServiceMonitor was never selected. It is a discovery problem, not a scrape problem.
# what does the Prometheus CR select, and from where?
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorSelector}{"\n"}'
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorNamespaceSelector}{"\n"}'
# {"matchLabels":{"release":"kube-prometheus-stack"}} and {} (all namespaces)
# what does the ServiceMonitor carry, and what does it select?
kubectl -n payments get servicemonitor api \
-o jsonpath='{.metadata.labels}{"\n"}{.spec.selector}{"\n"}{.spec.endpoints[*].port}{"\n"}'
kubectl -n payments get svc api -o jsonpath='{.metadata.labels}{"\n"}{.spec.ports[*].name}{"\n"}'# three faults, three one-line fixes
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: api
namespace: payments
labels:
release: kube-prometheus-stack # (1) was missing: the Prometheus CR never selected it
spec:
selector:
matchLabels:
app.kubernetes.io/name: api # (2) was app=api; the Service is labelled differently
namespaceSelector:
matchNames:
- payments
endpoints:
- port: metrics # (3) was "8080": this must be the Service PORT NAME
path: /metrics
interval: 30s
---
apiVersion: v1
kind: Service
metadata:
name: api
namespace: payments
labels:
app.kubernetes.io/name: api
spec:
selector:
app.kubernetes.io/name: api
ports:
- name: metrics # a named port, or the ServiceMonitor has nothing to match
port: 8080
targetPort: 8080kubectl apply -f servicemonitor.yaml -f service.yaml
# the operator regenerates the config; watch it appear rather than assuming
kubectl -n monitoring get secret prometheus-kube-prometheus-stack-prometheus \
-o jsonpath='{.data.prometheus\.yaml\.gz}' | base64 -d | gunzip | grep -A5 payments
kubectl -n monitoring port-forward svc/kube-prometheus-stack-prometheus 9090:9090 &
curl -s 'http://localhost:9090/api/v1/targets?state=active' | grep -o '"job":"api"[^}]*health":"[a-z]*"'
curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=up{job="api"}'Why: “my ServiceMonitor does not work” is three different bugs wearing one costume, and they live at three different layers, which is why a fixed order beats intuition. The operator layer decides whether your ServiceMonitor is even converted into scrape config: the Prometheus CR’s serviceMonitorSelector and serviceMonitorNamespaceSelector must both match, and the kube-prometheus-stack chart defaults to selecting on a release label — miss it and your object is inert with no error anywhere. The selector layer decides which Services it covers, and pod labels are irrelevant here: a ServiceMonitor selects Services. The endpoint layer decides which port, and port means the Service port’s name, not a number — an unnamed port simply cannot be referenced. The single most valuable command in the whole task is decompressing the generated config secret, because it answers “did discovery happen?” definitively and in one step, instead of the three you would otherwise spend guessing. More decision trees in Prometheus and triaging workloads.
X12 · Turn traces into RED metrics and label them with Kubernetes context
Your services emit OpenTelemetry traces, but the platform has no request-rate or latency metrics at all — the application teams never instrumented metrics, and asking twelve teams to do so is not a plan. You also cannot tell which namespace or deployment a trace came from, because the SDK only knows the service name. You are going to derive the metrics from the traces you already have, and enrich the spans with cluster context on the way through.
Your task:
- Add the
k8sattributesprocessor so spans gain namespace, pod, node and deployment attributes — including the RBAC it needs, which it does not have. - Add the
spanmetricsconnector so the traces pipeline also feeds a metrics pipeline, keeping namespace and deployment as dimensions. - Export those metrics for Prometheus to scrape, and prove the series exist with the Kubernetes labels attached.
Done when: kubectl -n observability logs deploy/otel-collector | grep -ci forbidden returns 0, and curl -s http://localhost:8889/metrics | grep traces_span_metrics_calls_total returns series carrying both k8s_namespace_name and k8s_deployment_name labels. (7 points)
Show the worked solution
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 75
spike_limit_percentage: 15
k8sattributes:
auth_type: serviceAccount
passthrough: false
extract:
metadata:
- k8s.namespace.name
- k8s.pod.name
- k8s.node.name
- k8s.deployment.name
labels:
- tag_name: team
key: platform.acme.io/team
from: pod
pod_association:
- sources:
- from: resource_attribute
name: k8s.pod.ip
- sources:
- from: connection
batch:
timeout: 5s
send_batch_size: 1024
connectors:
spanmetrics:
histogram:
explicit:
buckets: [10ms, 50ms, 100ms, 250ms, 500ms, 1s, 5s]
dimensions:
- name: k8s.namespace.name
- name: k8s.deployment.name
- name: http.route
metrics_flush_interval: 15s
exporters:
otlp/jaeger:
endpoint: jaeger-collector.observability.svc:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
resource_to_telemetry_conversion:
enabled: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8sattributes, batch]
exporters: [otlp/jaeger, spanmetrics] # a connector is an exporter here
metrics/spanmetrics:
receivers: [spanmetrics] # ...and a receiver here
processors: [batch]
exporters: [prometheus]# without this the processor starts, logs "forbidden", and silently enriches nothing
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: otel-collector
rules:
- apiGroups: [""]
resources: ["pods", "namespaces", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["replicasets"] # required to derive k8s.deployment.name
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: otel-collector
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: otel-collector
subjects:
- kind: ServiceAccount
name: otel-collector
namespace: observabilitykubectl apply -f otel-rbac.yaml kubectl -n observability create cm otel-collector-conf --from-file=config.yaml \ --dry-run=client -o yaml | kubectl apply -f - kubectl -n observability rollout restart deploy/otel-collector kubectl -n observability logs deploy/otel-collector | grep -i forbidden # expect nothing kubectl -n observability port-forward deploy/otel-collector 8889:8889 & curl -s http://localhost:8889/metrics | grep traces_span_metrics_calls_total curl -s http://localhost:8889/metrics | grep traces_span_metrics_duration_milliseconds_bucket | head -3
Why: two capabilities, joined by one config file. A connector is the piece people have not met: it is an exporter on one pipeline and a receiver on another, which is how a stream of spans becomes a stream of metrics without leaving the collector. spanmetrics gives you rate, errors and duration for every instrumented service for free — the classic RED signals — and it is the single highest-leverage thing you can turn on when application teams have instrumented traces but not metrics. The dimensions list is what makes it useful: without it every service collapses into one undifferentiated series, and dimensions can only reference attributes that already exist on the span, which is exactly why k8sattributes must sit earlier in the traces pipeline than the connector. And k8sattributes is the chained trap: it talks to the API server, so it needs a ClusterRole, and deriving k8s.deployment.name means walking pod → ReplicaSet → Deployment, so replicasets must be in it. Missing RBAC does not crash the collector; it logs and enriches nothing, which looks exactly like a configuration mistake. Keep memory_limiter first and batch last, as always. See OpenTelemetry and Jaeger.
X13 · Alert on something that only exists in the logs
The payments service logs a stack trace beginning panic: when a particular race condition fires. It does not increment any metric, the request still returns 200 to a retrying client, and the only reason anyone knows is that a developer happened to be tailing logs. Instrumenting the application properly is a two-sprint job; the platform can close the gap this afternoon.
Your task:
- Write the LogQL that turns those log lines into a rate series broken down by app, and prove it returns data.
- Ship it to the Loki ruler as a rule group containing a recording rule and an alert, mounted where the ruler will actually find it.
- Point the ruler at Alertmanager and show the alert leave
inactivewhen panics are generated.
Done when: curl -s http://localhost:3100/prometheus/api/v1/rules | grep -c PaymentsPanicBurst returns at least 1, and after generating panic lines the same endpoint shows the alert in state pending or firing. (5 points)
Show the worked solution
# 1. get the query right interactively before you commit it to a rule file
kubectl -n observability port-forward svc/loki 3100:3100 &
curl -sG http://localhost:3100/loki/api/v1/query_range \
--data-urlencode 'query=sum by (app) (rate({namespace="payments"} |= `panic:` [5m]))' \
--data-urlencode 'step=60'
# equivalently, if logcli is on the desktop:
logcli query --since=1h 'sum by (app) (rate({namespace="payments"} |= `panic:` [5m]))'apiVersion: v1
kind: ConfigMap
metadata:
name: loki-rules-payments
namespace: observability
labels:
loki_rule: "true"
data:
# single-tenant Loki (auth_enabled: false) reads rules for the tenant "fake"
payments.yaml: |
groups:
- name: payments-logs
interval: 1m
rules:
- record: payments:log_panics:rate5m
expr: |
sum by (namespace, app) (rate({namespace="payments"} |= `panic:` [5m]))
- alert: PaymentsPanicBurst
expr: |
sum by (namespace, app) (rate({namespace="payments"} |= `panic:` [5m])) > 0.1
for: 5m
labels:
severity: critical
team: payments
annotations:
summary: "panics in {{ $labels.app }}"
description: "more than 0.1 panic lines per second for five minutes in {{ $labels.namespace }}"# the ruler half of the Loki config — rules are useless if nothing evaluates them
ruler:
storage:
type: local
local:
directory: /rules
rule_path: /tmp/loki/rules-temp
ring:
kvstore:
store: inmemory
enable_api: true
enable_alertmanager_v2: true
alertmanager_url: http://kube-prometheus-stack-alertmanager.monitoring.svc:9093# loki-rules-mount.yaml — a rule file the ruler cannot SEE is a rule file that never runs.
# It must be on disk under ruler.storage.local.directory / <tenant>.
spec:
template:
spec:
containers:
- name: loki
volumeMounts:
- name: loki-rules
mountPath: /rules/fake # single-tenant Loki uses the tenant id "fake"
readOnly: true
volumes:
- name: loki-rules
configMap:
name: loki-rules-paymentskubectl apply -f loki-rules.yaml kubectl -n observability patch statefulset loki --patch-file loki-rules-mount.yaml kubectl -n observability rollout restart statefulset/loki kubectl -n observability rollout status statefulset/loki curl -s http://localhost:3100/loki/api/v1/rules # the group, as loaded curl -s http://localhost:3100/prometheus/api/v1/rules | grep PaymentsPanicBurst # generate the signal, then watch the state machine move kubectl -n payments exec deploy/api -- sh -c 'for i in 1 2 3 4 5 6 7 8 9 10; do echo "panic: runtime error"; done' sleep 90 curl -s http://localhost:3100/prometheus/api/v1/rules | grep -o '"state":"[a-z]*"' | sort -u
Why: logs are the signal of last resort, and the whole art is converting them into something with the same shape as a metric so the rest of your alerting machinery works unchanged. LogQL does that in two halves: a stream selector ({namespace="payments"}) that must always come first because it is what makes the query cheap, then a line filter (|=) and an aggregation that produces a range vector Prometheus-style rules can evaluate. Two operational traps decide the marks. First, the Loki ruler is a separate component with its own storage configuration — a rules ConfigMap that is not mounted where ruler.storage.local.directory points is simply never read, and single-tenant Loki looks under the tenant directory fake, which is the detail nobody guesses. Second, the ruler needs alertmanager_url or the alert evaluates perfectly and notifies nobody. Keep the filter as specific as you can afford — a broad |= across a busy namespace is an expensive query running every interval, forever. See Loki and reliability & incidents.
Platform Architecture & Infrastructure — X14 to X16 (15 points)
☺ Like you’re 10: These three are about the boring things that save you: keeping a copy, being able to take a machine away without hurting anyone, and not throwing away the data when you tidy up.
Three self-contained tasks with the best points-per-minute on the paper after the Security pair. They sit late, which means candidates who fall behind never open them — do not be one. Background: platform architecture, storage & state, scaling & scheduling, reliability & incidents and Velero.
X14 · Schedule a backup, then restore it somewhere else
A namespace was deleted by a mis-scoped script last quarter and rebuilt by hand over two days. You are putting a real backup in place — and, because a backup nobody has ever restored is a rumour rather than a backup, you are proving the restore works by bringing the data back into a different namespace so it can be verified without touching production.
Your task:
- Diagnose why the first ad-hoc backup completes as
PartiallyFailedand fix it. - Create a
Schedulethat backs up thepaymentsnamespace nightly with a 30-day retention, excluding events, and take an immediate backup from it. - Restore that backup into
payments-drusing a namespace mapping, and verify the restored objects.
Done when: velero backup get shows the backup in phase Completed, velero restore get shows the restore Completed with 0 errors, and kubectl -n payments-dr get deploy,cm,secret,pvc lists the restored objects. (6 points)
Show the worked solution
# 1. a PartiallyFailed backup almost always starts at the storage location kubectl -n velero get backupstoragelocation # NAME PHASE LAST VALIDATED ACCESS MODE # default Unavailable ReadWrite kubectl -n velero describe backupstoragelocation default | tail -20 velero backup logs payments-firstrun | grep -i error # the PartiallyFailed one from the story kubectl -n velero logs deploy/velero | grep -i 'backup storage location' # fix the bucket / credentials secret, then wait for PHASE Available kubectl -n velero get backupstoragelocation -w
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: payments-nightly
namespace: velero
spec:
schedule: "0 2 * * *"
template:
includedNamespaces:
- payments
excludedResources:
- events
- events.events.k8s.io
snapshotVolumes: false
defaultVolumesToFsBackup: true # file-level backup: no CSI snapshot support needed
ttl: 720h0m0s # 30 dayskubectl apply -f schedule.yaml velero schedule get # 2. take one now rather than waiting for 02:00 velero backup create payments-adhoc --from-schedule payments-nightly --wait velero backup describe payments-adhoc --details velero backup get # PHASE Completed, ERRORS 0 # 3. restore into a different namespace so production is never touched velero restore create payments-dr --from-backup payments-adhoc \ --namespace-mappings payments:payments-dr --wait velero restore describe payments-dr --details velero restore logs payments-dr | grep -i warn kubectl -n payments-dr get deploy,cm,secret,pvc kubectl -n payments-dr get pods
Why: the three objects map onto three ideas. A BackupStorageLocation is the connection to object storage, and it validates itself on a timer — which is why a PartiallyFailed or stuck backup should send you there first rather than into the backup’s own logs. A Schedule is a template plus a cron expression, so anything you can put in a backup spec you can put in a schedule, and --from-schedule lets you take an on-demand backup with identical settings — much better than hand-writing a one-off and discovering later that the nightly one differs. A Restore with --namespace-mappings is what turns a backup into a tested backup: restoring into payments-dr proves the data and the manifests are both recoverable without a maintenance window and without risking the live namespace. Note defaultVolumesToFsBackup: true chooses file-level volume backup, which works on clusters with no CSI snapshot support — the usual situation on a practice cluster — and requires the node agent to be running. See Velero and storage & state.
X15 · Make a node drain that hangs finish safely
You need to take a node out for a kernel patch. kubectl drain has been sitting there for eleven minutes repeating Cannot evict pod as it would violate the pod's disruption budget. Someone suggests --disable-eviction, which would delete the pods outright and cause the exact outage the PDB exists to prevent. There is also a crash-looping pod on the node that the drain cannot get rid of either.
Your task:
- Diagnose why eviction is refused, using the disruption budget’s own status rather than the drain output.
- Correct the budget so a rolling disruption is possible, and make it able to evict an already-unhealthy pod.
- Give the workload a graceful shutdown so the drain does not drop in-flight requests, then drain the node successfully.
Done when: kubectl -n shop get pdb api -o jsonpath='{.status.disruptionsAllowed}' prints a number greater than 0, and kubectl drain <node> --ignore-daemonsets --delete-emptydir-data completes with exit status 0. (5 points)
Show the worked solution
# 1. the PDB status is the whole diagnosis in one line
kubectl -n shop get pdb
# NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
# api 3 N/A 0 40d
kubectl -n shop get deploy api -o jsonpath='{.spec.replicas}{"\n"}' # 3
# minAvailable == replicas -> zero disruptions are ever allowed. The drain can never finish.
kubectl -n shop get pdb api -o jsonpath='{.status}{"\n"}'
kubectl -n shop get pods -l app=api -o wideapiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api
namespace: shop
spec:
maxUnavailable: 1 # expressed relatively: survives replica changes
selector:
matchLabels:
app: api
unhealthyPodEvictionPolicy: AlwaysAllow # a broken pod can no longer deadlock the drain
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: shop
spec:
replicas: 4
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
terminationGracePeriodSeconds: 45
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: api
containers:
- name: api
image: ghcr.io/acme/api:5.1.0
lifecycle:
preStop:
exec:
# let endpoint removal propagate before the process starts shutting down
command: ["/bin/sh", "-c", "sleep 10"]
readinessProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5kubectl apply -f pdb.yaml -f deployment.yaml
kubectl -n shop get pdb api -o jsonpath='{.status.disruptionsAllowed}{"\n"}' # 1
kubectl cordon <node>
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=300s
echo $? # 0
kubectl -n shop get pods -l app=api -o wide # rescheduled, never below 3 ready
kubectl uncordon <node>Why: a PodDisruptionBudget constrains voluntary disruption — evictions from drains, autoscaler scale-downs, node upgrades — and minAvailable equal to the replica count means zero disruptions are permitted, so the drain waits forever by design. Prefer maxUnavailable for exactly this reason: it stays correct when someone scales the Deployment, whereas an absolute minAvailable silently becomes a deadlock the day replicas drop to match it. unhealthyPodEvictionPolicy: AlwaysAllow fixes the second deadlock in the story — by default a pod that is not Ready still counts against the budget, so one crash-looping pod can block a drain indefinitely. The graceful-shutdown half is what makes the drain safe rather than merely possible: eviction and endpoint removal are concurrent, so without a preStop delay the pod can stop accepting connections while proxies are still sending them. terminationGracePeriodSeconds must exceed the preStop sleep plus the real shutdown, or the kubelet SIGKILLs you mid-request. Remember a PDB does not protect you from involuntary disruption — a node that simply dies ignores it entirely. See reliability & incidents and scaling & scheduling.
X16 · Give a StatefulSet a deliberate data-retention policy
The platform’s ledger database runs as a StatefulSet. Two problems: scaling it down from three to two leaves an orphaned PersistentVolumeClaim that nobody cleans up and everybody pays for, while deleting the StatefulSet during a botched migration last year also deleted the claims and cost the team a restore. The team wants the opposite of both behaviours, and they want to canary a config change on one replica before it touches the others.
Your task:
- Write the StatefulSet with a headless Service, a volume claim template, and a claim retention policy that deletes on scale-down but retains on delete.
- Set a rolling-update partition so only the highest ordinal takes a new template first.
- Prove all three behaviours: stable DNS, the scale-down deletion, and the retention on delete.
Done when: kubectl -n platform exec ledger-db-0 -- nslookup ledger-db-1.ledger-db resolves to the pod IP, scaling to 2 leaves kubectl -n platform get pvc without data-ledger-db-2, and after kubectl -n platform delete sts ledger-db the remaining PVCs are still listed. (4 points)
Show the worked solution
apiVersion: v1
kind: Service
metadata:
name: ledger-db
namespace: platform
spec:
clusterIP: None # headless: gives each pod a stable DNS name
selector:
app: ledger-db
ports:
- name: pg
port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: ledger-db
namespace: platform
spec:
serviceName: ledger-db # must name the headless Service above
replicas: 3
podManagementPolicy: OrderedReady
persistentVolumeClaimRetentionPolicy:
whenScaled: Delete # scale down -> the surplus claim goes with it
whenDeleted: Retain # delete the set -> the data survives
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 2 # only ordinals >= 2 take the new template
selector:
matchLabels:
app: ledger-db
template:
metadata:
labels:
app: ledger-db
spec:
terminationGracePeriodSeconds: 30
containers:
- name: db
image: postgres:16-alpine
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: ledger-db
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- name: pg
containerPort: 5432
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources:
requests:
storage: 1Gikubectl -n platform create secret generic ledger-db --from-literal=password=s3cr3t kubectl apply -f statefulset.yaml kubectl -n platform rollout status sts/ledger-db # stable identity kubectl -n platform get pvc # data-ledger-db-0 .. -2 kubectl -n platform exec ledger-db-0 -- nslookup ledger-db-1.ledger-db # busybox is always there # whenScaled: Delete kubectl -n platform scale sts ledger-db --replicas=2 kubectl -n platform get pvc # data-ledger-db-2 is gone # whenDeleted: Retain kubectl -n platform delete sts ledger-db kubectl -n platform get pvc # data-ledger-db-0 and -1 still there
Why: a StatefulSet buys you three guarantees a Deployment cannot give: stable network identity (each pod gets <name>-<ordinal>.<service> DNS, which requires the headless Service named in serviceName), stable storage (each ordinal keeps its own PVC across rescheduling, because the claim is named after the ordinal rather than the pod), and ordered operations. persistentVolumeClaimRetentionPolicy is the newer piece and it splits a decision that used to be all-or-nothing: historically every claim was retained forever, so scale-downs quietly leaked storage. Setting whenScaled: Delete with whenDeleted: Retain encodes exactly the intent here — routine capacity changes clean up after themselves, while destroying the object never destroys the data. The partition is the StatefulSet’s canary control: with partition: 2 a template change rolls only ordinals 2 and above, so you validate on one replica and then lower the number to continue. Compare the reclaim policy on the StorageClass, which governs the PV rather than the claim, in storage & state.
Security & Policy Enforcement — X17 to X18 (15 points)
☺ Like you’re 10: Two tasks: check that the parcel really came from your own factory before you open it, and take away the master key somebody left lying on the doormat.
Only two tasks, but fifteen points — the highest points-per-task on the paper, and the pair candidates most often never reach. If you are behind schedule when you get here, do these before you go back to anything you flagged. Background: security & policy enforcement, Sigstore & cosign, Kyverno, secrets management and governance & compliance.
X17 · Refuse to run an image you cannot prove you built
An incident review found that a typosquatted image ran in production for six hours: the tag looked right, the registry path was one character out, and nothing in the platform checked provenance. Builds are already signed with cosign, but nothing verifies the signature at admission. One namespace, legacy-batch, runs vendor images that will never be signed and cannot be migrated this quarter.
Your task:
- Sign a build with cosign and confirm the signature verifies from the command line.
- Write a Kyverno
ClusterPolicythat verifies signatures for your registry, requires a signature, and rewrites the image reference to a digest on admission. - Carve out
legacy-batchwith aPolicyExceptionrather than by weakening the policy. - Prove all three outcomes: signed admitted with a digest, unsigned rejected, unsigned in the excepted namespace admitted.
Done when: kubectl -n shop get pod signed -o jsonpath='{.spec.containers[0].image}' contains @sha256:, an unsigned image in shop is rejected with an image-verification message, and the same unsigned image creates successfully in legacy-batch. (8 points)
Show the worked solution
# 1. sign, then verify from the CLI before you ever involve admission control cosign generate-key-pair # cosign.key + cosign.pub # keep signing and verification consistent with the policy's rekor.ignoreTlog below: # sign without a transparency-log entry, and tell verify not to demand one cosign sign --key cosign.key --tlog-upload=false -y ghcr.io/acme/checkout:3.4.0 cosign verify --key cosign.pub --insecure-ignore-tlog=true ghcr.io/acme/checkout:3.4.0 | head cat cosign.pub # this goes into publicKeys, verbatim
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-images
spec:
validationFailureAction: Enforce
background: false
webhookTimeoutSeconds: 30
failurePolicy: Fail
rules:
- name: verify-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "ghcr.io/acme/*"
required: true # unsigned is a rejection, not a shrug
mutateDigest: true # rewrite tag -> digest so the verified bytes are what runs
verifyDigest: true
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEXAMPLEEXAMPLEEXAMPLEEXAMPLE
EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE==
-----END PUBLIC KEY-----
rekor:
ignoreTlog: true # key-based signing without a transparency logapiVersion: kyverno.io/v2
kind: PolicyException
metadata:
name: allow-legacy-batch
namespace: kyverno
spec:
exceptions:
- policyName: verify-images
ruleNames:
- verify-signature
- autogen-verify-signature # the auto-generated Deployment/Job variant
match:
any:
- resources:
kinds:
- Pod
namespaces:
- legacy-batchkubectl apply -f verify-images.yaml
kubectl get clusterpolicy verify-images # READY True before testing
# exceptions must be switched on, and the namespace holding them allow-listed
kubectl -n kyverno get deploy kyverno-admission-controller \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i exception
kubectl apply -f policy-exception.yaml
kubectl -n shop run signed --image=ghcr.io/acme/checkout:3.4.0 --restart=Never
kubectl -n shop get pod signed -o jsonpath='{.spec.containers[0].image}{"\n"}' # ...@sha256:...
kubectl -n shop run bad --image=ghcr.io/acme/checkout:tampered --restart=Never
# error: failed to verify image ghcr.io/acme/checkout:tampered: .attestors[0].entries[0].keys: no matching signatures
kubectl -n legacy-batch run bad --image=ghcr.io/acme/checkout:tampered --restart=Never # allowed
kubectl get policyreport -A | grep verify-imagesWhy: signing without verification is theatre — the signature exists and nothing consults it, which is precisely the gap the incident exposed. verifyImages closes it at admission, and three fields carry the actual security. required: true is the difference between “reject unsigned images” and “verify signatures that happen to be present”, which is a much weaker statement than it sounds. mutateDigest: true is the subtle one: verification happens against a digest, but a tag can be repointed at different bytes a second later, so rewriting the reference to @sha256:… is what guarantees the thing you verified is the thing the kubelet pulls. And the exception rather than a namespace-exclusion inside the policy is the governance point — a PolicyException is a separate, auditable object with its own review trail, so “who allowed unsigned images and where?” has an answer, whereas an exclude block buried in the policy quietly weakens it for everyone. Two operational gotchas: exceptions must be enabled on the Kyverno deployment and confined to an allow-listed namespace, and you must except the autogen- rule variants too, or the exception covers bare Pods while Deployments are still blocked. See Sigstore & cosign and Trivy.
X18 · Shut down a service account that can become cluster-admin
A routine review found that deploy-bot in the payments namespace is bound to cluster-admin — added “temporarily” two years ago so a CI job could create a CRD. It can read every Secret in the cluster and create ClusterRoleBindings, which means anyone who obtains its token owns the cluster. Every pod in the namespace also automounts a token by default, so the blast radius is every workload there, not just the CI job.
Your task:
- Audit from the caller’s identity: establish exactly what
deploy-botcan do today, and which bindings grant it. - Replace the cluster-admin binding with the least privilege the job actually needs, and prove the escalation paths are closed.
- Stop the namespace automounting tokens, and give the one workload that needs an identity a short-lived, audience-bound projected token instead.
Done when: kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:payments:deploy-bot prints no, kubectl auth can-i get secrets --as=system:serviceaccount:payments:deploy-bot -A prints no, and kubectl -n payments exec deploy/deploy-bot -- cat /var/run/secrets/tokens/api-token returns a JWT whose aud claim is vault. (7 points)
Show the worked solution
# 1. audit from the identity, never from your own admin context
SA=system:serviceaccount:payments:deploy-bot
kubectl auth can-i --list --as=$SA
kubectl auth can-i create clusterrolebindings --as=$SA # yes <- full escalation
kubectl auth can-i get secrets --as=$SA -A # yes <- every secret
# which binding is responsible?
kubectl get clusterrolebinding -o jsonpath='{range .items[?(@.roleRef.name=="cluster-admin")]}{.metadata.name}{"\t"}{.subjects[*].name}{"\n"}{end}'
# anyone holding escalate or bind can grant themselves anything, so check those too
kubectl get clusterroles -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.rules[*].verbs}{"\n"}{end}' | grep -E 'escalate|bind|\*'apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deploy-bot
namespace: payments
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "patch", "update"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: deploy-bot
namespace: payments
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: deploy-bot
subjects:
- kind: ServiceAccount
name: deploy-bot
namespace: paymentsapiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: payments
automountServiceAccountToken: false # nothing in this namespace gets a token by accident
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: deploy-bot
namespace: payments
spec:
replicas: 1
selector:
matchLabels:
app: deploy-bot
template:
metadata:
labels:
app: deploy-bot
spec:
serviceAccountName: deploy-bot
automountServiceAccountToken: false # no ambient API token...
containers:
- name: bot
image: ghcr.io/acme/deploy-bot:2.0.1
volumeMounts:
- name: api-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: api-token
projected:
sources:
- serviceAccountToken:
path: api-token # ...only this narrow, expiring one
audience: vault
expirationSeconds: 3600kubectl delete clusterrolebinding deploy-bot-admin
kubectl apply -f rbac.yaml -f serviceaccount.yaml -f deployment.yaml
kubectl auth can-i create clusterrolebindings --as=$SA # no
kubectl auth can-i get secrets --as=$SA -A # no
kubectl auth can-i patch deployments --as=$SA -n payments # yes — the job still works
kubectl auth can-i --list --as=$SA -n payments
# the projected token is short-lived and bound to one audience
kubectl -n payments exec deploy/deploy-bot -- cat /var/run/secrets/tokens/api-token \
| cut -d. -f2 | base64 -d 2>/dev/null
# {"aud":["vault"],"exp":...,"kubernetes.io":{"namespace":"payments",...}}
# and no long-lived token Secret is left lying around
kubectl -n payments get secrets --field-selector type=kubernetes.io/service-account-tokenWhy: the audit half and the fix half are equally examinable. Auditing means asking the API server from the caller’s identity with --as, because your own context is almost certainly cluster-admin and everything looks permitted; kubectl auth can-i --list --as= is the single most useful RBAC command there is. Two verbs deserve their own search: escalate lets a subject create a Role with permissions it does not itself hold, and bind lets it bind an existing powerful role — either one is equivalent to cluster-admin with an extra step, and neither is obvious from a role’s name. The fix half turns on how tokens work now. Modern Kubernetes issues bound service account tokens: time-limited, audience-scoped, tied to the pod, and rotated automatically — a stolen one expires and is rejected by any service that checks its audience. A legacy kubernetes.io/service-account-token Secret, by contrast, never expires and is valid everywhere, which is why the last check looks for one. Setting automountServiceAccountToken: false on the namespace’s default ServiceAccount removes the ambient credential from every workload that never needed one, and the projected volume gives the single workload that does need one a credential scoped to exactly the audience it talks to. See security & policy and secrets management.
First, enforcing before measuring. Both security tasks above have a measure step — verifying with the CLI before involving admission control in X17, auditing with --as before deleting a binding in X18 — and skipping it is how a policy change becomes an outage. Deleting a cluster-admin binding without first listing what the subject actually uses breaks a CI system at 09:00 on a Monday, and you will not know which of the removed permissions mattered. Second, testing as yourself. You are almost certainly cluster-admin on your practice cluster, and admin bypasses more than you think: PSA exempts nothing by default, but Kyverno policies commonly exempt system namespaces and service accounts, and ValidatingAdmissionPolicyBinding matching is easy to get subtly wrong. Always verify from the identity and the namespace that will actually be affected.
Score yourself
☺ Like you’re 10: Add up your points, turn it into a percentage, and then — more usefully — look at which questions you lost points on and go and read about those.
Mark after a break; marking your own work while still adrenalised produces generous nonsense. Award full points only when the done-when check actually passed on the cluster, half when the resource exists and is broadly right but the check did not pass, and zero when you did not attempt it. On a chained paper, be strict about the halves: “the AnalysisTemplate was fine but the recording rule never loaded” is half marks, not full, because in production it would still have paged someone.
| Task | Domain | Points | Your score |
|---|---|---|---|
| X1 · Canary analysis with a missing recording rule | GitOps | 7 | |
| X2 · AppProject fence and a sync window | GitOps | 4 | |
| X3 · Flux image automation writing back to Git | GitOps | 5 | |
| X4 · Tekton matrix, when and finally | GitOps | 5 | |
| X5 · Custom Argo CD Lua health check | GitOps | 4 | |
| X6 · Claimable XRD gated by a Kyverno policy | Platform APIs | 6 | |
| X7 · Backstage template plus ValidatingAdmissionPolicy | Platform APIs | 6 | |
| X8 · Server-side apply field ownership | Platform APIs | 5 | |
| X9 · Controller RBAC: accepted then ignored | Platform APIs | 4 | |
| X10 · ResourceQuota on custom resource counts | Platform APIs | 4 | |
| X11 · ServiceMonitor that scrapes nothing | Observability | 8 | |
| X12 · spanmetrics plus k8sattributes and its RBAC | Observability | 7 | |
| X13 · Loki ruler alert from log lines | Observability | 5 | |
| X14 · Velero schedule, backup and mapped restore | Architecture | 6 | |
| X15 · Unblock and make safe a hanging drain | Architecture | 5 | |
| X16 · StatefulSet claim retention and partition | Architecture | 4 | |
| X17 · Verify image signatures with an exception | Security | 8 | |
| X18 · Close a service account escalation path | 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%. Then do the more useful arithmetic and total each domain separately: a 70 made of five even domains and a 70 made of four strong domains plus a zero are completely different results, and the second one fails a real exam the day the task draw is unkind.
| 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 — and on this paper that is the pile to watch, because seven tasks were deliberately broken. Work the troubleshooting playbook, triaging delivery and triaging networking until the evidence order is automatic.
What your score means
☺ Like you’re 10: One number does not tell you whether you will pass — but the shape of it tells you what to practise next, which is more useful anyway.
Treat the number as a diagnostic, never as a prediction, and treat it more gently than the earlier papers. Set 6 is deliberately the hardest thing on the site: the tasks chain, seven of them start broken, and nothing is single-step. A score here is not comparable to a score on Set 1 or Set 2, and it should not be — if you scored 78 on Set 2 and 66 here, you have not gone backwards.
| Score | Read it as | Next move |
|---|---|---|
| 80–100 | Ready with real margin. You handled chained, broken and unfamiliar together. | Stop grinding tasks. Work the final checklist, skim the field notes for exam-day logistics, and sit the real thing. |
| 64–79 | A pass on the hardest paper here — a genuinely good sign, with no margin to waste. | Re-drill only your weakest domain, then sit this paper again in a week and compare the domain shape, not the total. |
| 50–63 | Close. On this paper that is usually triage speed plus one weak domain, not missing knowledge. | A fortnight of timed drills from the bank, plus what to know cold for every shape you had to look up. |
| Under 50 | Gaps across domains, or you ran out of clock before the tail. Check which before you conclude anything. | Back to the lessons for the two lowest domains, then the lab track to rebuild hands-on fluency before another mock. |
Two patterns worth checking independently of the total. How many tasks did you leave completely unattempted? More than two and your problem is pacing rather than knowledge — the cheapest thing on this list to fix. And how did you do on the seven broken tasks (X1, X5, X8, X9, X11, X14, X15) as a group? If those seven are where your points went, the fix is not more study, it is the fixed evidence order: read what the system actually loaded before you edit anything, and always ask the API from the affected identity rather than your own.
Dot: Sixty-six. On the hardest paper. I would like that engraved on something.
Timmy: Domain breakdown, please. The total is the least interesting number you have.
Dot: GitOps 22, Platform APIs 21, Observability 8, Architecture 11, Security 4.
Benny: Eight out of twenty on Observability. What happened on X11?
Dot: I rewrote the ServiceMonitor four times. Four correct times. It was never the ServiceMonitor — the Prometheus CR was not selecting it at all.
Recon: BEEP. One command would have told you that in nine seconds. Decompress the generated config secret. If your object is not in it, discovery never happened and editing the object is editing a thing nobody is reading.
Gizmo: Or — hear me out — just give everything cluster-admin and turn the policies off. Instant twenty points. 🤑
Timmy: That is task X18, Gizmo. Someone did exactly that two years ago “temporarily”, and it is now worth seven marks to undo.
Foxy: The bit that got me was X1. I knew the AnalysisTemplate was right, so I kept staring at it.
Professor Owl: Which is the lesson of the whole paper, and worth more than the score. When a thing you configured has no effect, the question is never “is my YAML correct?” It is “is my YAML loaded?” Ask the running system what it believes, and half of these eighteen tasks collapse into one command.
1. Which documentation may you have open during the real exam, and name three popular projects whose own docs are not permitted. 2. A PrometheusRule applies cleanly but its series never appears — what is the first thing to check, and what is the definitive command? 3. Argo CD reports a custom resource as Healthy without evaluating anything. Why, and what breaks as a result? 4. In server-side apply, what exactly does a field manager own, and why is a smaller apply document safer than a cleverer flag? 5. Your controller’s objects are stored but never reconciled and the pod is Running. Name the three subresource or event grants people most often forget. 6. In Kyverno verifyImages, what do required: true and mutateDigest: true each buy you? 7. A drain hangs with “would violate the disruption budget”. Give the two PDB fields that resolve it and say what each one fixes. 8. Which two RBAC verbs are equivalent to cluster-admin with one extra step?
Check your answers
- 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. - Its labels versus the Prometheus CR’s
ruleSelector— the kube-prometheus-stack selects onrelease, and a mismatched rule applies with no error and never evaluates. The definitive check is what Prometheus actually loaded:curl /api/v1/rules, or decompressing the generated config secret withbase64 -d | gunzip. - Argo CD has no built-in health assessment for unknown kinds and defaults them to Healthy. With sync waves that makes the wave boundary meaningless — the next wave starts while the resource is still provisioning. Fix it with a Lua script under
resource.customizations.health.<group>_<kind>that returnsProgressingby default. - A manager owns exactly the fields present in its last apply. So an apply document containing only the fields you intend to own cannot steal anything else, whatever
--force-conflictsdoes — and dropping a field from a later apply deliberately releases it. <resource>/status(get/update/patch — a separate subresource, so conditions fail silently without it),<resource>/finalizers(update), andevents(create/patch, or the controller cannot tell anyone what went wrong).required: truemakes an unsigned image a rejection rather than a pass — without it you only verify signatures that happen to exist.mutateDigest: truerewrites the tag to the verified@sha256:digest, so the bytes you verified are the bytes that actually run.maxUnavailable: 1instead of aminAvailableequal to the replica count — that is what makes any disruption allowed at all; andunhealthyPodEvictionPolicy: AlwaysAllow, which stops an already-broken pod counting against the budget and deadlocking the drain.escalate(create a Role holding permissions you do not have) andbind(bind an existing powerful role to yourself). Search for both, plus wildcard verbs, in any RBAC audit.
That is the whole paper, and the hardest one on the site. Score it, write the domain breakdown and the broken-task subtotal somewhere you will see them, and let those two numbers choose your last week. When they stop moving, the remaining work is logistical rather than technical: the exam guide covers booking, the environment and the rules; the field notes collect what candidates actually reported from the day; the final checklist is the last thing to read before you sit down; and the command reference, what to know cold, the docs map and the glossary are worth a last skim on the morning. Go again from the drill bank, warm up on Set 1, or head back to the course home to pick a lesson.