Exam Prep · Mock Exam · Set 4

Mock Exam · Set 4

Another whole paper, sealed. Eighteen performance-based tasks weighted exactly to the CNPE blueprint, sat in one unbroken 120-minute block on a throwaway cluster with only the permitted documentation open. Every task on this page is new: none of them appears in Set 1, none repeats Set 2, and none is lifted from the five practice banks. Where a topic recurs — Argo CD sync behaviour, Kyverno, KEDA — the mechanism is deliberately different, because the exam does not ask you the same question twice either. Each task gives you a realistic platform situation, two to four numbered steps, an objective done when check you can run yourself, and a point value. The worked solutions are folded away so you can sit the paper honestly and mark it afterwards. Total the sheet at the end, compare it against the 64% bar, and let the misses choose your next fortnight.

☺ Explain it like I’m 10

You have already played two full matches, and you know roughly how good you are. This is the away game: same rules, same clock, but the other team plays differently — they pass where the last lot dribbled, so the moves you had memorised do not quite fit. That is the whole point. If you can only win when the game looks exactly like practice, you have not learned football, you have learned one match. Play this one, then look at which parts of your game only worked because you had seen the play before.

🐢🦫Your hosts for this topic: Timmy the Turtle & Benny the Beaver — Timmy holds one stopwatch and refuses to stop it for a broken CRD, a coffee or a crash-looping pod, and Benny marks nothing that has not passed its done-when check on a real cluster with his own eyes.

Before you start — exam conditions

☺ Like you’re 10: A practice match only teaches you something if you play it by the real rules — same clock, no help, no re-runs.

The comfortable mock is the useless mock. A pause to install something, a quick search for a field name, a peek at one solution when a manifest will not validate — each one is small, and together they produce a score that flatters you and teaches nothing. Set the conditions first; start the clock second.

One — build the cluster the night before. A throwaway kind or minikube cluster is enough, with Argo CD, Argo Rollouts, Tekton Pipelines and Tekton Triggers, the kube-prometheus-stack, a Jaeger instance with an OpenTelemetry Collector in front of it, KEDA, Crossplane with a provider installed, Kyverno and the External Secrets Operator. Karpenter needs a real cloud cluster; if you only have kind, do the Karpenter task on paper and mark it half at most. If a component 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 nobody will let you.

Two — one timer, started once. 120 minutes, no pausing. If you lose eight minutes to your own laptop, that is data rather than an injustice: the real exam eats time in ways you did not plan for too. Sit it at roughly the hour your real exam is booked.

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

Four — no copy-paste from the solutions, and no AI assistance of any kind. If you stall, spend your budget, fail the task, and read the solution after the sitting. A task you pasted your way through is a task you will fail again on exam day, only more expensively.

⚠ The allowlist is narrow — and everything here can change

That list reshapes how you prepare. For every tool whose own documentation is not allowed, the resource shape has to be in your head or reachable from what to know cold — you cannot look up an Application, a TriggerTemplate, a ScaledJob, a NodePool or an ExternalSecret mid-exam. What you can always do is interrogate the cluster: kubectl explain <kind> --recursive, kubectl api-resources and kubectl get <kind> <existing> -o yaml are not documentation, they are the live API, and they are your real reference. Separately: exam specifics move. Task counts, the 120-minute duration, the 64% pass mark, domain weights, the permitted-resource list and the Kubernetes version on the desktop have all changed before and will change again. Everything on this page is a study aid written at a point in time — the official CNCF certification pages and the Linux Foundation training site are the only authority. Confirm before you book.

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

Your time budget

☺ Like you’re 10: Give every question its own small slice of the two hours. When a question eats more than its slice, put a sticky note on it and move on — you can always come back.

Eighteen tasks in 120 minutes is six and a half minutes each on average, but they are not equal and you should not treat them as though they are. Budget deliberately: six minutes to read everything, 108 minutes of task work, and six minutes at the end to re-run your done-when checks. That last block earns more points per minute than anything else on the paper, because half-finished work you never verified is exactly where points quietly die.

BlockTasksBudgetRunning totalNote
Read the whole paper6 min6Mark every task cheap / medium / expensive before you touch a terminal.
GitOps & Continuous DeliveryT1–T56 + 6 + 7 + 6 + 6 = 31 min37Biggest domain. T3 (Triggers) has the most objects to type — start it early or not at all.
Platform APIs & Self-ServiceT6–T105 + 7 + 6 + 6 + 5 = 29 min66T6 and T10 are the two cheapest tasks on the paper. Do them first.
Observability & OperationsT11–T138 + 7 + 6 = 21 min87T11 is the single most expensive task. T13 is triage and may resolve in ninety seconds.
Platform Architecture & InfrastructureT14–T166 + 5 + 5 = 16 min103Three self-contained tasks with excellent points-per-minute.
Security & Policy EnforcementT17–T186 + 5 = 11 min114Two tasks worth 15 points between them — the highest value on the paper. Never skip.
Verify & sweep flagged tasks6 min120Re-run every done-when. Finish the nearly-done before you start anything new.

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

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

Points, not tasks, are the unit of this exam. With a 64% bar you can fail four or five tasks outright and still pass comfortably — but only if you actually opened the cheap ones. Candidates who fail are rarely the ones who knew too little; they are the ones who spent twenty minutes on a Crossplane claim and never reached the two security tasks worth fifteen points at the bottom of the paper.

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

☺ Like you’re 10: These five are about the robot that reads instructions from a folder: how it lays things down, what it should politely ignore, how a webhook wakes the build up, how to build a container without giving the builder the keys to the whole machine, and how to test a new version on the side before letting anyone use it.

The biggest slice of the blueprint and the one where speed pays most. Background reading lives in GitOps workflows, CI/CD & progressive delivery and release engineering, and the hands-on versions are the GitOps lab and the delivery lab — read and run those after the sitting, never during it.

T1 · Fix a sync that fails on annotation size, and let Argo CD own the namespace

The platform-addons Application will not sync. The controller reports metadata.annotations: Too long: must have at most 262144 bytes on one of the Prometheus Operator CRDs, and a second error says the destination namespace observability does not exist. A previous engineer “fixed” this by applying the CRDs by hand, which is why nobody trusts the sync status any more.

Your task:

  1. Switch the Application to server-side apply so the whole manifest is no longer stuffed into an annotation.
  2. Have Argo CD create the destination namespace itself, and stamp it with a Pod Security label and an owner annotation that Argo CD keeps reconciled.
  3. Sync, and prove Argo CD now owns the CRD through an Apply entry in managedFields rather than through a last-applied annotation.

Done when: argocd app sync platform-addons completes without the Too long error, kubectl get ns observability -o jsonpath='{.metadata.labels}' shows the label you declared, and kubectl get crd prometheuses.monitoring.coreos.com -o jsonpath='{.metadata.managedFields[?(@.manager=="argocd-controller")].operation}' prints Apply — the ownership record now lives in managedFields, so Argo CD no longer has to write the manifest into an annotation at all. (5 points)

Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: platform-addons
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/acme/platform-config.git
    targetRevision: main
    path: infrastructure/observability
  destination:
    server: https://kubernetes.default.svc
    namespace: observability
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    managedNamespaceMetadata:          # Argo CD owns these on the namespace it creates
      labels:
        pod-security.kubernetes.io/enforce: baseline
        platform.acme.io/managed: "true"
      annotations:
        platform.acme.io/owner: platform-team
    syncOptions:
      - CreateNamespace=true
      - ServerSideApply=true
      - ApplyOutOfSyncOnly=true
# per-resource override, when only one object needs it:
metadata:
  annotations:
    argocd.argoproj.io/sync-options: ServerSideApply=true
kubectl apply -f platform-addons-app.yaml
argocd app sync platform-addons
argocd app get platform-addons

kubectl get ns observability -o jsonpath='{.metadata.labels}{"\n"}'
kubectl get crd prometheuses.monitoring.coreos.com \
  -o jsonpath='{.metadata.managedFields[*].manager}{"\n"}'
kubectl get crd prometheuses.monitoring.coreos.com \
  -o jsonpath='{.metadata.managedFields[?(@.manager=="argocd-controller")].operation}{"\n"}'   # Apply

Why: client-side apply keeps a full copy of the manifest in the kubectl.kubernetes.io/last-applied-configuration annotation so it can compute a three-way merge — and the API server caps all annotations on an object at 256 KiB, which the big operator CRDs blow straight past. Server-side apply moves that bookkeeping into metadata.managedFields on the server, so Argo CD writes no annotation at all and several controllers can co-own different fields of the same object without fighting. (An object that was previously client-side applied by hand can keep its old, stale last-applied-configuration annotation until somebody strips it — that is cosmetic; what fixes the sync is that nothing writes a new one.) CreateNamespace=true makes Argo CD create the destination namespace instead of failing, and managedNamespaceMetadata lets it own that namespace’s labels and annotations without you shipping a separate Namespace manifest — which is how the Pod Security label stops being something a human remembers. More in Argo CD.

T2 · Stop a second controller from making an app permanently OutOfSync

The checkout Application shows OutOfSync forever, and two legitimate second owners are the reason. A HorizontalPodAutoscaler holds the Deployment at 11 replicas while Git says 4, so kube-controller-manager and Argo CD overwrite spec.replicas in turn; and cert-manager’s cainjector writes a caBundle into the ValidatingWebhookConfiguration the app ships, which Git cannot contain because the CA is generated in-cluster. Self-heal reverts both, the controllers write them back, and deploy frequency has halved because nobody can tell a real diff from this one.

Your task:

  1. Configure the checkout Application to ignore whatever the autoscaler’s field manager owns on the Deployment.
  2. Additionally ignore the injected caBundle by matching the webhook entry by name rather than by array index, so the rule survives a reordering.
  3. Make sure automated self-heal honours both exclusions, and show the app is Synced with the scaled replica count and the injected CA still in place.

Done when: kubectl -n argocd get app checkout -o jsonpath='{.status.sync.status}' prints Synced while kubectl -n checkout get deploy checkout -o jsonpath='{.spec.replicas}' still shows the autoscaler’s count rather than the number in Git, kubectl get validatingwebhookconfiguration checkout-validate -o jsonpath='{.webhooks[0].clientConfig.caBundle}' is non-empty, and argocd app diff checkout; echo $? prints 0. (5 points)

Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: checkout
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/acme/platform-config.git
    targetRevision: main
    path: apps/checkout/overlays/prod
  destination:
    server: https://kubernetes.default.svc
    namespace: checkout
  ignoreDifferences:
    # 1. drop everything a named field manager owns — the right tool for a second controller
    - group: apps
      kind: Deployment
      namespace: checkout
      managedFieldsManagers:
        - kube-controller-manager     # the HPA writes spec.replicas through this manager
    # 2. and match the injected array element by name, not by index
    - group: admissionregistration.k8s.io
      kind: ValidatingWebhookConfiguration
      name: checkout-validate
      jqPathExpressions:
        - '.webhooks[] | select(.name == "validate.checkout.acme.io") | .clientConfig.caBundle'
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - RespectIgnoreDifferences=true   # without this, self-heal reverts anyway
kubectl apply -f checkout-app.yaml
argocd app get checkout
argocd app diff checkout; echo $?        # 0 means no diff at all

kubectl -n argocd get app checkout -o jsonpath='{.status.sync.status}{"\n"}'
kubectl -n checkout get deploy checkout -o jsonpath='{.spec.replicas}{"\n"}'   # the HPA's number
kubectl get validatingwebhookconfiguration checkout-validate \
  -o jsonpath='{.webhooks[0].clientConfig.caBundle}{"\n"}' | head -c 40; echo
kubectl -n checkout get deploy checkout \
  -o jsonpath='{range .metadata.managedFields[*]}{.manager}{" "}{.operation}{"\n"}{end}'   # who owns what

Why: an autoscaler is not drift — it is a second legitimate owner of part of the object, and the fix has to say so rather than paper over a diff. managedFieldsManagers tells Argo CD to subtract from the diff everything a named field manager owns, so you never enumerate the fields yourself and the rule keeps working when that controller changes them. Know its limit, because the exam likes it: this only works against a separate controller that writes the object in its own request. A mutating admission webhook’s patch is applied inside the original request, so the API server attributes it to the requester — Argo CD’s own argocd-controller — and there is no separate manager entry to name. For those, and for anything inside an array whose index you cannot predict, use jqPathExpressions with a select() on a stable key such as the webhook or container name. And exactly as with any ignore rule, ignoreDifferences alone only silences the diff — automated self-heal recomputes its own target and will still re-apply until you add the RespectIgnoreDifferences=true sync option. Keep every rule scoped to a kind, a namespace and where possible a name; a group-wide ignore hides real drift. More in Argo CD and cert-manager.

T3 · Start a build from a webhook with Tekton Triggers

Builds currently start when somebody remembers to run tkn pipeline start, which means the image in staging is routinely three commits behind. The team wants a push to main to start a PipelineRun automatically, with the commit SHA carried through as a real parameter rather than resolved later by a script.

Your task:

  1. Create the ServiceAccount and RoleBinding the EventListener needs to create PipelineRuns in ci.
  2. Create a TriggerBinding that extracts the commit SHA and clone URL from a GitHub push payload, and a TriggerTemplate that turns them into a PipelineRun with a per-run workspace.
  3. Create an EventListener with a GitHub interceptor that verifies the HMAC signature and only accepts push events, then fire a test payload and prove a run appeared.

Done when: kubectl -n ci get eventlistener build-listener -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True, and after posting a signed sample payload, tkn pipelinerun list -n ci shows a new run whose revision param equals the SHA you sent. (5 points)

Show the worked solution
apiVersion: v1
kind: ServiceAccount
metadata:
  name: tekton-triggers-sa
  namespace: ci
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: tekton-triggers-eventlistener
  namespace: ci
subjects:
  - kind: ServiceAccount
    name: tekton-triggers-sa
    namespace: ci
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: tekton-triggers-eventlistener-roles    # shipped by the Triggers install
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding                       # ALSO required: interceptors are cluster-scoped
metadata:
  name: tekton-triggers-eventlistener-clusterbinding
subjects:
  - kind: ServiceAccount
    name: tekton-triggers-sa
    namespace: ci
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: tekton-triggers-eventlistener-clusterroles
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
  name: github-push
  namespace: ci
spec:
  params:
    - name: gitrevision
      value: $(body.head_commit.id)
    - name: gitrepositoryurl
      value: $(body.repository.clone_url)
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
  name: build-template
  namespace: ci
spec:
  params:
    - name: gitrevision
    - name: gitrepositoryurl
  resourcetemplates:
    - apiVersion: tekton.dev/v1
      kind: PipelineRun
      metadata:
        generateName: build-
      spec:
        pipelineRef:
          name: build-and-deploy
        params:
          - name: revision
            value: $(tt.params.gitrevision)
          - name: repo-url
            value: $(tt.params.gitrepositoryurl)
        workspaces:
          - name: shared
            volumeClaimTemplate:
              spec:
                accessModes: ["ReadWriteOnce"]
                resources:
                  requests:
                    storage: 1Gi
---
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
  name: build-listener
  namespace: ci
spec:
  serviceAccountName: tekton-triggers-sa
  triggers:
    - name: github-push
      interceptors:
        - ref:
            name: github
          params:
            - name: secretRef
              value:
                secretName: github-webhook-secret
                secretKey: secretToken
            - name: eventTypes
              value: ["push"]
      bindings:
        - ref: github-push
      template:
        ref: build-template
kubectl -n ci create secret generic github-webhook-secret \
  --from-literal=secretToken="$WEBHOOK_TOKEN"
kubectl apply -f triggers.yaml
kubectl -n ci get eventlistener build-listener
kubectl -n ci get pods -l eventlistener=build-listener      # the sink runs as a pod

kubectl -n ci port-forward svc/el-build-listener 8080:8080 &

BODY='{"head_commit":{"id":"a1b2c3d4e5f6"},"repository":{"clone_url":"https://github.com/acme/checkout.git"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_TOKEN" | awk '{print $2}')

curl -sS -X POST http://localhost:8080 \
  -H 'Content-Type: application/json' \
  -H 'X-GitHub-Event: push' \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  -d "$BODY"

tkn pipelinerun list -n ci
kubectl -n ci get pipelinerun --sort-by=.metadata.creationTimestamp \
  -o jsonpath='{.items[-1:].spec.params[?(@.name=="revision")].value}{"\n"}'
kubectl -n ci logs -l eventlistener=build-listener --tail=40   # where silent failures show up

Why: Triggers deliberately splits webhook handling into three objects so each part is reusable. The EventListener is an HTTP sink that runs as a real pod behind a Service called el-<name>; TriggerBindings pull fields out of whatever JSON arrived; TriggerTemplates describe the resources to create, parameterised with $(tt.params.…). Two things fail silently and account for most lost time here: the listener runs under a ServiceAccount and without both bindings — a namespaced RoleBinding to create PipelineRuns and a ClusterRoleBinding so it can read the cluster-scoped ClusterInterceptor objects — it will happily return 202 Accepted and create nothing, and an interceptor that rejects the payload (wrong signature, wrong event type) also returns success while doing nothing — so read the listener’s own logs, not the HTTP status. Validating the HMAC in an interceptor rather than in a pipeline step is what stops anyone on the network starting builds. See Tekton.

T4 · Build an image in-cluster without a privileged container

Your build pipeline runs a Docker-in-Docker sidecar with privileged: true. Security has given you two weeks to remove it: a malicious Dockerfile in that pod owns the node’s kernel, and the same node runs other tenants’ workloads.

Your task:

  1. Write a Tekton Task that builds and pushes an image with Kaniko, taking source from a source workspace and registry credentials from a dockerconfig workspace.
  2. Mount a kubernetes.io/dockerconfigjson Secret under the filename the Docker credential chain actually reads, and run the step unprivileged with no privilege escalation, dropping every capability and adding back only those the build genuinely needs.
  3. Run it and emit the pushed digest as a Task result.

Done when: the TaskRun succeeds, kubectl -n ci get taskrun --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].status.results[?(@.name=="IMAGE_DIGEST")].value}' prints a sha256:… digest, and kubectl -n ci get pods -l tekton.dev/taskRun -o jsonpath='{.items[*].spec.containers[*].securityContext.privileged}' contains no true while the same query for .allowPrivilegeEscalation contains no true either. (5 points)

Show the worked solution
apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: kaniko-build
  namespace: ci
spec:
  params:
    - name: IMAGE
      type: string
    - name: DOCKERFILE
      type: string
      default: ./Dockerfile
    - name: CONTEXT
      type: string
      default: ./
  workspaces:
    - name: source
    - name: dockerconfig
      optional: true
      mountPath: /kaniko/.docker      # where the credential chain looks
  results:
    - name: IMAGE_DIGEST
      description: Digest of the image that was pushed
    - name: IMAGE_URL
      description: Fully qualified image reference
  steps:
    - name: build-and-push
      image: gcr.io/kaniko-project/executor:v1.23.2
      workingDir: $(workspaces.source.path)
      env:
        - name: DOCKER_CONFIG
          value: /kaniko/.docker
      args:
        - --dockerfile=$(params.DOCKERFILE)
        - --context=$(workspaces.source.path)/$(params.CONTEXT)
        - --destination=$(params.IMAGE)
        - --digest-file=$(results.IMAGE_DIGEST.path)
      securityContext:
        runAsUser: 0                  # root INSIDE the container, not on the node
        privileged: false
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
          # least privilege, not zero privilege: unpacking a base image
          # restores file ownership and modes, so these are still required
          add: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETGID", "SETUID"]
    - name: record-url
      image: alpine:3.20
      script: |
        #!/bin/sh
        printf '%s' "$(params.IMAGE)" > $(results.IMAGE_URL.path)
apiVersion: tekton.dev/v1
kind: TaskRun
metadata:
  generateName: kaniko-build-
  namespace: ci
spec:
  taskRef:
    name: kaniko-build
  params:
    - name: IMAGE
      value: ghcr.io/acme/checkout:2.7.0
  workspaces:
    - name: source
      persistentVolumeClaim:
        claimName: source-cache
    - name: dockerconfig
      secret:
        secretName: ghcr-push          # type kubernetes.io/dockerconfigjson
        items:
          - key: .dockerconfigjson     # the key a docker-registry secret uses
            path: config.json          # the filename kaniko/docker expects
kubectl -n ci create secret docker-registry ghcr-push \
  --docker-server=ghcr.io --docker-username=acme-ci --docker-password="$GHCR_TOKEN"

kubectl create -f kaniko-taskrun.yaml
tkn taskrun logs -f -n ci --last

kubectl -n ci get taskrun --sort-by=.metadata.creationTimestamp \
  -o jsonpath='{.items[-1:].status.results[?(@.name=="IMAGE_DIGEST")].value}{"\n"}'
kubectl -n ci get pods -l tekton.dev/taskRun \
  -o jsonpath='{.items[*].spec.containers[*].securityContext.privileged}{"\n"}'
kubectl -n ci get pods -l tekton.dev/taskRun \
  -o jsonpath='{.items[*].spec.containers[*].securityContext.allowPrivilegeEscalation}{"\n"}'

Why: Docker-in-Docker needs a privileged container because the daemon wants to manage cgroups, namespaces and a storage driver on the host — which hands any Dockerfile in your pipeline the node’s kernel. Kaniko instead unpacks the base image and executes each Dockerfile instruction inside its own container filesystem in userspace: it still needs UID 0 and a handful of file-ownership capabilities within that container to restore a base image’s permissions, but it needs no privileged: true, no host mounts, no host PID or network and no access to the container runtime socket — which is the distinction the exam is testing. Dropping ALL and adding back only CHOWN, DAC_OVERRIDE, FOWNER, SETGID and SETUID is the honest version; dropping ALL with nothing added looks stricter on the page and fails the moment the base image has a file owned by anyone but root. The credential mapping is the other detail people lose time on: a docker-registry Secret stores its payload under .dockerconfigjson, but the credential chain reads a file called config.json, so the workspace has to remap the key to that path. Kaniko itself is no longer actively developed; Buildah and rootless BuildKit are the current equivalents and take the same shape, so learn the pattern rather than the flags.

T5 · Gate a blue-green promotion on analysis, at a fraction of the cost

The checkout service is already deployed blue-green, but two things are wrong. Running a full second copy of eight pods for every release doubles the cost of the deployment window, and “promotion” is a human clicking a button after glancing at a dashboard — which is how last month’s 8% error rate reached 100% of traffic.

Your task:

  1. Run the preview stack at a fraction of production scale while auto-promotion stays off.
  2. Add an AnalysisTemplate that queries Prometheus for the preview’s error ratio, and attach it as prePromotionAnalysis with the service name passed in as an argument.
  3. Trigger a bad revision and show the rollout degrades without the active Service ever pointing at it.

Done when: during the rollout kubectl argo rollouts get rollout checkout -n checkout shows the preview ReplicaSet at the reduced count while the active stays at 8; kubectl -n checkout get analysisrun -o jsonpath='{.items[-1:].status.phase}' prints Failed; and kubectl -n checkout get svc checkout-active -o jsonpath='{.spec.selector.rollouts-pod-template-hash}' is unchanged from the value you noted before triggering. (5 points)

Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: preview-error-rate
  namespace: checkout
spec:
  args:
    - name: service
  metrics:
    - name: error-ratio
      interval: 30s
      count: 4                       # four samples before a verdict
      failureLimit: 1
      successCondition: result[0] <= 0.01
      provider:
        prometheus:
          address: http://prometheus-operated.monitoring.svc:9090
          query: |
            sum(rate(http_requests_total{service="{{args.service}}",code=~"5.."}[2m]))
            /
            sum(rate(http_requests_total{service="{{args.service}}"}[2m]))
---
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: checkout
  namespace: checkout
spec:
  replicas: 8
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      containers:
        - name: checkout
          image: ghcr.io/acme/checkout:3.4.0
          ports:
            - containerPort: 8080
  strategy:
    blueGreen:
      activeService: checkout-active
      previewService: checkout-preview
      previewReplicaCount: 2          # 25% of production while under test
      autoPromotionEnabled: false
      scaleDownDelaySeconds: 120      # old stack stays warm for instant rollback
      abortScaleDownDelaySeconds: 30
      prePromotionAnalysis:
        templates:
          - templateName: preview-error-rate
        args:
          - name: service
            value: checkout-preview
kubectl -n checkout get svc checkout-active \
  -o jsonpath='{.spec.selector.rollouts-pod-template-hash}{"\n"}'   # note this first

# the plugin, not `kubectl set image` — that only understands built-in workload kinds
kubectl argo rollouts set image checkout checkout=ghcr.io/acme/checkout:3.5.0 -n checkout
kubectl argo rollouts get rollout checkout -n checkout --watch

kubectl -n checkout get analysisrun
kubectl -n checkout get analysisrun -o jsonpath='{.items[-1:].status.phase}{"\n"}'
kubectl -n checkout get rollout checkout -o jsonpath='{.status.phase}{"\n"}'   # Degraded

kubectl -n checkout get svc checkout-active \
  -o jsonpath='{.spec.selector.rollouts-pod-template-hash}{"\n"}'   # unchanged
kubectl argo rollouts abort checkout -n checkout
kubectl argo rollouts undo checkout -n checkout

Why: blue-green’s cost is a whole second copy of the workload, and previewReplicaCount is the lever that makes the bill proportional to the test rather than to production — the controller scales the preview stack up to full size only once analysis passes and promotion begins, so you are never serving live traffic at reduced capacity. prePromotionAnalysis is what turns the human click into a measurement: the AnalysisRun samples the metric count times against successCondition, and a failure marks the Rollout Degraded while the active Service selector never moves — the bad version was real, deployed and observed, and no user ever saw it. Note that the arg indirection (args on the Rollout feeding {{args.service}} in the template) is what lets one AnalysisTemplate serve every service on the platform. Compare with canary weighting in Argo Rollouts and progressive delivery.

🦆 Dot’s-eye view

“Four of those five are invisible to me and I feel every one. Server-side apply means the sync status stopped lying, so I trust the dashboard during an incident again. The ignore rule means ‘OutOfSync’ means something. The webhook means my build starts before I have finished writing the PR description. And the analysis gate means the worst release I ever shipped was seen by two synthetic requests and nobody else. None of that came with a runbook — the platform just stopped having those failure modes.”

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

☺ Like you’re 10: These five are about the new kinds of object your platform invents: how to rescue one that refuses to be deleted, how to stop its caretaker robot spinning in circles, how to find out why a request for a database is stuck, how to turn a README into a form, and how to stop people asking for things they should not have.

The other 25% domain. Roughly half of it is mechanics you can drill to reflex; the rest is judgement about what to expose and what to keep behind the curtain. Background: Platform APIs & operators, self-service & golden paths and IaC & control planes, with the keyboard work in the Platform APIs lab.

T6 · Rescue objects stuck Terminating behind an orphaned finalizer

Someone uninstalled the Environment operator before deleting its custom resources. Three Environment objects have been Terminating for six days, the demo namespace that contains them is stuck Terminating too, and a colleague is thirty seconds away from running kubectl delete ns demo --force and calling it fixed. The cloud resources those environments guarded were already torn down by hand last week.

Your task:

  1. Show from evidence — not from a guess — exactly what is blocking each deletion.
  2. Unblock the objects without force-deleting the namespace and without touching the namespace’s own finalize subresource.
  3. Confirm the namespace terminates on its own once the blocker clears.

Done when: kubectl get environments -A returns no resources, kubectl get ns demo returns NotFound, and you can state which finalizer string you removed and why it was safe. (5 points)

Show the worked solution
# 1. EVIDENCE — a Terminating object always tells you both halves
kubectl -n demo get environment demo \
  -o jsonpath='{.metadata.deletionTimestamp}{"\n"}{.metadata.finalizers}{"\n"}'

# the namespace names its own blockers in status.conditions
kubectl get ns demo -o jsonpath='{range .status.conditions[*]}{.type}{": "}{.message}{"\n"}{end}'

# is the controller that owns that finalizer even running?
kubectl get crd environments.platform.acme.io
kubectl -n platform-system get deploy,pods

# what else is still alive in the namespace?
kubectl api-resources --verbs=list --namespaced -o name \
  | xargs -n1 kubectl get -n demo --show-kind --ignore-not-found
# 2. remove ONLY the orphaned finalizer, on the object that carries it
kubectl -n demo patch environment demo --type=json \
  -p '[{"op":"remove","path":"/metadata/finalizers/0"}]'

# same thing for every remaining object, once you have confirmed each is safe
for e in $(kubectl get environments -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'); do
  kubectl -n "${e%%/*}" patch environment "${e##*/}" --type=merge \
    -p '{"metadata":{"finalizers":null}}'
done

# 3. the namespace finishes terminating by itself — no force, no /finalize
kubectl get environments -A
kubectl get ns demo

Why: a finalizer is nothing more than a string in metadata.finalizers, and its entire power is that the API server refuses to remove the object while the list is non-empty. kubectl delete on such an object does not delete it: it sets metadata.deletionTimestamp and returns, and the controller that put the string there is supposed to notice the timestamp, do its external cleanup, and then remove its own string — that removal is what actually deletes the object. Uninstall the controller first and nobody will ever remove it, so the object, and any namespace containing it, waits forever. Two traps: --force --grace-period=0 does not bypass a finalizer, and clearing the namespace’s spec.finalizers through the /finalize endpoint makes the namespace disappear while leaving its contents stranded in etcd — unreachable but still counted. Removing the specific object finalizer is the correct recovery, and it is only correct because you first confirmed the external resource it was guarding is genuinely gone. Read the contract again in Platform APIs & operators.

T7 · Stop a controller reconciling itself in a hot loop

The Environment controller pegs a full CPU core on a cluster with twelve Environment objects and nothing changing. controller_runtime_reconcile_total climbs by several thousand a minute, the API server audit log is a wall of identical PATCH requests, and kubectl wait --for=condition=Ready against an Environment sometimes returns immediately even when the object is mid-provision. The reconciler writes status on every pass and returns Requeue: true.

Your task:

  1. Make the reconcile idempotent: write status only when a condition actually changed, using metav1.Condition with observedGeneration.
  2. Replace the unconditional requeue with a periodic resync, and make sure status is written through the status subresource so it cannot bump metadata.generation.
  3. Add the CRD plumbing that makes kubectl wait --for=condition=Ready and a Ready printer column work.

Done when: controller_runtime_reconcile_total for the controller grows by fewer than 20 over two minutes on an idle cluster, and kubectl wait --for=condition=Ready environment/demo -n demo --timeout=60s prints condition met while kubectl get environment demo -n demo shows a READY column. (5 points)

Show the worked solution
func (r *EnvironmentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var env platformv1.Environment
    if err := r.Get(ctx, req.NamespacedName, &env); err != nil {
        // gone: stop cleanly, never requeue a NotFound
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // ... do the actual work, which must itself be idempotent ...

    ready := metav1.Condition{
        Type:               "Ready",
        Status:             metav1.ConditionTrue,
        Reason:             "Provisioned",
        Message:            "all child resources are healthy",
        ObservedGeneration: env.Generation,
    }

    // SetStatusCondition reports whether it changed anything (apimachinery v0.27+)
    if meta.SetStatusCondition(&env.Status.Conditions, ready) {
        if err := r.Status().Update(ctx, &env); err != nil {
            // a conflict is normal; controller-runtime requeues with backoff
            return ctrl.Result{}, err
        }
    }

    // periodic resync, not a busy loop
    return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}

func (r *EnvironmentReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&platformv1.Environment{},
            builder.WithPredicates(predicate.GenerationChangedPredicate{})).
        Complete(r)
}
# the CRD side: a status subresource, a typed conditions array, a printer column
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Ready
          type: string
          jsonPath: .status.conditions[?(@.type=="Ready")].status
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                tier: { type: string, enum: [dev, staging, prod] }
            status:
              type: object
              properties:
                conditions:
                  type: array
                  x-kubernetes-list-type: map
                  x-kubernetes-list-map-keys: [type]
                  items:
                    type: object
                    required: [type, status, lastTransitionTime, reason]
                    properties:
                      type:               { type: string }
                      status:             { type: string, enum: ["True", "False", "Unknown"] }
                      reason:             { type: string }
                      message:            { type: string }
                      observedGeneration: { type: integer, format: int64 }
                      lastTransitionTime: { type: string, format: date-time }
kubectl -n platform-system port-forward deploy/environment-controller-manager 8080:8080 &
curl -s localhost:8080/metrics | grep -E 'controller_runtime_reconcile_total|workqueue_depth'
sleep 120
curl -s localhost:8080/metrics | grep controller_runtime_reconcile_total   # barely moved

kubectl get environment demo -n demo
kubectl wait --for=condition=Ready environment/demo -n demo --timeout=60s
kubectl get environment demo -n demo \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].observedGeneration}{"\n"}'

Why: the hot loop is self-inflicted and needs both halves of the fix. Requeue: true re-enqueues immediately (rate-limited, but immediately), and a controller that also writes on every pass emits a watch event that re-triggers itself — so the loop feeds itself forever. Writing only on a real change breaks the feedback; writing through the status subresource means metadata.generation is untouched, so a GenerationChangedPredicate filters the event out entirely. RequeueAfter then gives you what you actually wanted: a slow safety net that catches drift the watch missed. observedGeneration on the condition is what lets a caller distinguish “Ready, and that verdict is about the current spec” from “Ready, about a spec from three edits ago” — without it, kubectl wait --for=condition=Ready is a coin flip immediately after an update. More in Kubebuilder.

T8 · Diagnose a Crossplane claim that never becomes Ready

A developer created a PostgresInstance claim forty minutes ago. kubectl describe on the claim says only that it is waiting for the composite resource to become ready. The composite says it is waiting for its composed resources. The developer’s instinct is to delete the claim and try again; the platform team’s standing rule is that you never do that before you know what failed.

Your task:

  1. Walk the chain claim → composite → managed resources and identify which object is actually failing.
  2. Read the two conditions that mean different things on the failing managed resource, and name the root cause from them.
  3. Fix it and bring the existing claim to Ready — without deleting or re-creating the claim.

Done when: kubectl -n team-a get postgresinstance db-1 -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True, and kubectl get managed shows every composed resource with SYNCED=True READY=True. (5 points)

Show the worked solution
# 1. FOLLOW THE CHAIN — the claim names its composite, the composite names its children
kubectl -n team-a get postgresinstance db-1 \
  -o jsonpath='{.spec.resourceRef.kind}{" "}{.spec.resourceRef.name}{"\n"}'

XR=$(kubectl -n team-a get postgresinstance db-1 -o jsonpath='{.spec.resourceRef.name}')
kubectl get xpostgresinstance "$XR" -o yaml | sed -n '/status:/,$p'
kubectl describe xpostgresinstance "$XR" | sed -n '/Events/,$p'   # composition errors land here

kubectl get managed          # the SYNCED / READY columns point at the culprit

# 2. THE TWO CONDITIONS SAY DIFFERENT THINGS
kubectl get instance.rds.aws.upbound.io "$XR-db" \
  -o jsonpath='{.status.conditions[?(@.type=="Synced")].message}{"\n"}'
kubectl get instance.rds.aws.upbound.io "$XR-db" \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}{"\n"}'
kubectl describe instance.rds.aws.upbound.io "$XR-db" | sed -n '/Events/,$p'

# the usual culprits behind Synced=False
kubectl get providers
kubectl get providerconfigs.aws.upbound.io
kubectl -n crossplane-system logs -l pkg.crossplane.io/provider --tail=50
# ROOT CAUSE: the Composition points at a ProviderConfig name that does not exist,
# so every composed resource sits at Synced=False, "cannot get referenced ProviderConfig".
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default                  # the exact name the Composition references
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-creds
      key: creds
---
# and the base in the Composition must reference it explicitly
            base:
              apiVersion: rds.aws.upbound.io/v1beta1
              kind: Instance
              spec:
                forProvider:
                  region: eu-west-1
                  engine: postgres
                  instanceClass: db.t3.micro
                  allocatedStorage: 20
                providerConfigRef:
                  name: default
kubectl apply -f providerconfig.yaml
kubectl apply -f composition.yaml            # no need to touch the claim

kubectl get managed -w
kubectl -n team-a get postgresinstance db-1 \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'

Why: Crossplane’s chain is claim → composite (XR) → managed resource → cloud API, and every layer politely reports “waiting for the layer below”, which makes the claim’s message the least informative one in the system. Skip to kubectl get managed and then read the two conditions, because they answer different questions: Synced means “Crossplane reconciled this spec with the provider API” — false points at credentials, a missing or misnamed ProviderConfig, or a field the provider rejected; Ready means “the external resource exists and is usable” — false with Synced=True usually just means an RDS instance is still creating and you should wait. Composition-level failures (a bad patch, a missing function) appear as events on the composite, not on the claim, which is why kubectl describe on the XR is the single highest-value command here. Resist deleting the claim: it destroys the evidence and, with the default deletionPolicy: Delete, can take a real database with it. See Crossplane.

T9 · Turn a README golden path into a Backstage software template

Your “golden path” is a README that says copy the checkout repo and change the names. In practice teams copy an eighteen-month-old repo, forget the catalog registration, and the service catalog is now about 40% stale — which means the ownership data your incident process depends on is wrong.

Your task:

  1. Write a scaffolder.backstage.io/v1beta3 Template with validated parameters: a name matching a DNS-label pattern, an owner picked from real Group entities, and a repository location restricted to your GitHub organisation.
  2. Add steps that render the skeleton, publish the repository, and register the produced catalog-info.yaml, then expose the repo and the new catalog entity as output links.
  3. Register the template itself in the catalog and prove both the template and a scaffolded component resolve through the catalog API.

Done when: curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=template,metadata.name=node-service' | jq -r '.[].metadata.name' returns node-service, and after running it, the same query with kind=component and the new name returns the new Component with the owner you selected. (5 points)

Show the worked solution
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: node-service
  title: Node.js service (golden path)
  description: Creates a Node.js service repo with CI and a registered catalog entry.
  tags: [recommended, nodejs]
spec:
  type: service
  owner: group:default/platform-team
  parameters:
    - title: Service
      required: [name, owner]
      properties:
        name:
          title: Name
          type: string
          description: Lowercase DNS label; becomes the repo and component name.
          pattern: '^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'
          maxLength: 30
          ui:autofocus: true
        owner:
          title: Owner
          type: string
          description: The team that will be paged for this service.
          ui:field: OwnerPicker
          ui:options:
            catalogFilter:
              kind: Group
    - title: Repository
      required: [repoUrl]
      properties:
        repoUrl:
          title: Location
          type: string
          ui:field: RepoUrlPicker
          ui:options:
            allowedHosts: [github.com]
            allowedOwners: [acme]
  steps:
    - id: fetch
      name: Render the skeleton
      action: fetch:template
      input:
        url: ./skeleton
        values:
          name: ${{ parameters.name }}
          owner: ${{ parameters.owner }}

    - id: publish
      name: Publish to GitHub
      action: publish:github
      input:
        repoUrl: ${{ parameters.repoUrl }}
        description: ${{ parameters.name }}
        defaultBranch: main
        repoVisibility: private

    - 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 catalog
        icon: catalog
        entityRef: ${{ steps.register.output.entityRef }}
# skeleton/catalog-info.yaml — rendered by fetch:template, then registered
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: ${{ values.name }}
  annotations:
    github.com/project-slug: acme/${{ values.name }}
    backstage.io/techdocs-ref: dir:.
spec:
  type: service
  lifecycle: experimental
  owner: ${{ values.owner }}
  system: checkout
# app-config.yaml — the template only exists once the catalog is told about it
catalog:
  locations:
    - type: url
      target: https://github.com/acme/software-templates/blob/main/node-service/template.yaml
      rules:
        - allow: [Template]
curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=template,metadata.name=node-service' \
  | jq -r '.[].metadata.name'

# after running the template from the UI:
curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=component,metadata.name=orders-api' \
  | jq -r '.[] | .metadata.name + " owned by " + .spec.owner'

Why: a golden path is only golden when the only work left is filling in a form, and that means the form has to make invalid answers impossible. The pattern keeps the name a legal DNS label so the same string can be a repo, a namespace and a Service; OwnerPicker with a catalogFilter forces the owner to be a Group that actually exists, which is what stops the catalog filling with dangling ownership references; RepoUrlPicker with allowedOwners keeps new services inside your organisation. The step that turns a repo generator into a platform is catalog:register — without it the component exists but the catalog never learns about it, which is precisely the 40% staleness you started with. One nuance worth holding: ${{ parameters.x }} and ${{ steps.y.output.z }} are evaluated by the scaffolder as it runs, while ${{ values.x }} inside the skeleton is rendered by the fetch:template action into the generated files. See Backstage and developer experience.

T10 · Guard your own API with a ValidatingAdmissionPolicy

Your Environment API lets any team request tier: prod. Three teams have quietly created prod environments in the sandbox cluster, one of which now holds real customer data. You want a cluster-side guardrail that needs no policy engine installed, cannot be bypassed by a mutating controller running later in the chain, and does not take the cluster down with it if a webhook pod dies.

Your task:

  1. Write a ValidatingAdmissionPolicy whose CEL rejects an Environment with spec.tier: prod unless its namespace carries platform.acme.io/prod-approved: "true".
  2. Bind it with a ValidatingAdmissionPolicyBinding limited to namespaces labelled as tenants, with validationActions that both deny and audit.
  3. Prove it denies in an unapproved namespace and admits in an approved one.

Done when: kubectl -n team-a apply -f prod-env.yaml is rejected with your own message, the identical file applies successfully in team-approved, and kubectl get validatingadmissionpolicy env-tier-guard -o jsonpath='{.status.typeChecking}' reports no CEL type errors. (5 points)

Show the worked solution
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: env-tier-guard
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups:   ["platform.acme.io"]
        apiVersions: ["v1beta1"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["environments"]
  matchConditions:
    - name: only-prod-requests            # cheap short-circuit: skip everything else
      # tier is optional in the schema, so probe before you read it — a CEL error
      # here is a request failure, and failurePolicy: Fail would deny it
      expression: 'has(object.spec.tier) && object.spec.tier == "prod"'
  variables:
    - name: approved
      expression: >-
        has(namespaceObject.metadata.labels) &&
        'platform.acme.io/prod-approved' in namespaceObject.metadata.labels &&
        namespaceObject.metadata.labels['platform.acme.io/prod-approved'] == 'true'
  validations:
    - expression: variables.approved
      reason: Forbidden
      message: "tier=prod is only allowed in namespaces labelled platform.acme.io/prod-approved=true"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: env-tier-guard
spec:
  policyName: env-tier-guard
  validationActions: [Deny, Audit]
  matchResources:
    namespaceSelector:
      matchExpressions:
        - key: platform.acme.io/tenant
          operator: Exists
kubectl apply -f env-tier-guard.yaml
kubectl get validatingadmissionpolicy env-tier-guard -o jsonpath='{.status.typeChecking}{"\n"}'

kubectl label ns team-a          platform.acme.io/tenant=acme-a
kubectl label ns team-approved   platform.acme.io/tenant=acme-b platform.acme.io/prod-approved=true

kubectl -n team-a apply -f prod-env.yaml           # denied, with your message
kubectl -n team-approved apply -f prod-env.yaml    # created
kubectl -n team-approved get environment prod-1

Why: a ValidatingAdmissionPolicy is admission control inside the API server: CEL expressions, no webhook pod, no TLS certificate to rotate, and no failure mode where your policy engine being down takes the cluster with it. Two objects are always required and forgetting the second is the classic first mistake — the policy says what to check, the binding says where it applies and whether a violation denies, audits or merely warns; a policy with no binding is completely inert. matchConditions is a cheap pre-filter evaluated before the expensive validations, so the guard costs nothing on the 99% of requests it does not care about. And status.typeChecking is the feature that saves you on exam day: the API server type-checks your CEL against the matched resources when you create the policy, so a typo in a field path surfaces immediately instead of at the moment it wrongly rejects a real request. Compare the webhook-based approach in security & policy and Gatekeeper.

🐢 Timmy’s drill · 12 min

Before you sit the paper, warm up on pure speed rather than knowledge. From a cold terminal, and using only kubectl explain --recursive and an existing object as your reference, produce a valid skeleton for each of these in under three minutes each: an Argo CD Application with two sync options, a ValidatingAdmissionPolicy plus its binding, a KEDA ScaledJob, and a Karpenter NodePool with three requirements. Time each one. Any that takes over three minutes is your highest-value study target on the whole site — ahead of any concept, because on the day you will not have the project documentation to lean on.

Observability & Operations — T11 to T13 (20 points)

☺ Like you’re 10: These three are about noticing problems properly: doing the sums in advance so the dashboard is fast, following one single request to see where its time went, and working out why the measuring tape was never attached in the first place.

Twenty percent of the paper, and the domain that rewards a fixed method most. When a triage task starts, resist the urge to guess — walk the same evidence order every single time. Background: observability, reliability & incidents and the troubleshooting playbook, plus the observability lab and the troubleshooting lab.

T11 · Replace a threshold alert with tested burn-rate alerting

The availability dashboard takes forty seconds to load because every panel recomputes a ratio over raw counters. Meanwhile the single CheckoutErrorRateHigh alert — 1% errors over five minutes — pages on every deploy blip, and it missed a fortnight-long 0.4% error rate that quietly consumed the whole month’s error budget. The SLO is 99.9% over 30 days.

Your task:

  1. Add recording rules that pre-compute the error ratio at 5m, 30m, 1h and 6h.
  2. Replace the single alert with a multi-window multi-burn-rate pair: a critical page at 14.4× (1h and 5m windows) and a warning ticket at 6× (6h and 30m windows).
  3. Unit-test the rules with promtool so you can prove the page fires on a fast burn — then ship them as a PrometheusRule.

Done when: promtool check rules slo-rules.yaml prints SUCCESS, promtool test rules slo-tests.yaml reports all tests passed, and after applying, kubectl -n monitoring get prometheusrule checkout-slo -o jsonpath='{.spec.groups[*].name}' lists both groups while the rules appear under /api/v1/rules in Prometheus. (7 points)

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

    - name: checkout-slo-alerting
      rules:
        - alert: CheckoutErrorBudgetBurnFast
          # 14.4x burn on a 0.1% budget = the whole 30-day budget gone in ~2 days
          expr: |
            job:slo_errors:ratio_rate1h > (14.4 * 0.001)
            and
            job:slo_errors:ratio_rate5m > (14.4 * 0.001)
          for: 2m
          labels:
            severity: critical
            team: checkout
          annotations:
            summary: "Checkout is burning its error budget 14.4x too fast"
            description: "At this rate the 30-day budget is gone in about 2 days."

        - alert: CheckoutErrorBudgetBurnSlow
          # 6x burn = budget gone in ~5 days. A ticket, not a page.
          expr: |
            job:slo_errors:ratio_rate6h > (6 * 0.001)
            and
            job:slo_errors:ratio_rate30m > (6 * 0.001)
          for: 15m
          labels:
            severity: warning
            team: checkout
          annotations:
            summary: "Checkout is burning its error budget 6x too fast"
# slo-tests.yaml — promtool unit tests: 5% errors is a fast burn and must page
rule_files:
  - slo-rules.yaml

evaluation_interval: 1m

tests:
  - interval: 1m
    input_series:
      - series: 'http_requests_total{job="checkout",code="200"}'
        values: '0+95x180'
      - series: 'http_requests_total{job="checkout",code="500"}'
        values: '0+5x180'
    alert_rule_test:
      - eval_time: 90m
        alertname: CheckoutErrorBudgetBurnFast
        exp_alerts:
          - exp_labels:
              severity: critical
              team: checkout
            exp_annotations:
              summary: "Checkout is burning its error budget 14.4x too fast"
              description: "At this rate the 30-day budget is gone in about 2 days."
# promtool wants a plain rules file, so lift .spec out of the PrometheusRule
kubectl -n monitoring get prometheusrule checkout-slo -o jsonpath='{.spec}' | yq -P > slo-rules.yaml

promtool check rules slo-rules.yaml
promtool test rules slo-tests.yaml

kubectl apply -f checkout-slo.yaml
kubectl -n monitoring get prometheusrule checkout-slo -o jsonpath='{.spec.groups[*].name}{"\n"}'
curl -s localhost:9090/api/v1/rules | jq -r '.data.groups[].name'
curl -s --get localhost:9090/api/v1/query \
  --data-urlencode 'query=job:slo_errors:ratio_rate1h' | jq '.data.result'

Why: recording rules move expensive work from query time to evaluation time — the dashboard reads one cheap pre-computed series instead of re-aggregating raw counters per panel, and the alert expressions become short enough to be reviewable. The multi-window multi-burn-rate pattern then fixes both failure modes of a naive threshold at once: the long window (1h, 6h) supplies significance so a thirty-second blip cannot page, and the short window (5m, 30m) supplies fast reset so the alert clears when the burn stops rather than lingering for an hour. The two rates are chosen from the budget, not from taste: at 14.4× a 30-day budget is exhausted in roughly two days, which justifies waking someone; at 6× it takes about five days, which is a ticket. And promtool test rules is the part almost nobody does — an alert you have never seen fire is a hypothesis. One operational trap: the Prometheus Operator only loads a PrometheusRule whose labels match the Prometheus CR’s ruleSelector, so a perfectly correct rule file can sit in the cluster doing nothing. See Prometheus.

T12 · Find a latency regression that every dashboard says is fine

p99 latency on /checkout jumped from 180 ms to 2.4 s after Tuesday’s release. Every individual service’s dashboard looks healthy — each reports a normal median and a normal error rate — and the team has spent a day arguing about which service is at fault. Traces are in Jaeger and the collector is emitting span metrics.

Your task:

  1. Pull the slowest traces for the checkout service’s /checkout operation from the Jaeger query API rather than clicking around the UI.
  2. Determine whether the added time is one slow span or many repeated ones, and name the span responsible.
  3. State the fix, and add a platform-side guardrail that would have caught it — an alert on the fan-out ratio from span metrics.

Done when: a query with minDuration=2s returns traces before the fix and none after; the span-name histogram for one trace shows the repeated operation dropping from over 120 occurrences to fewer than 10; and the new alert rule loads in Prometheus. (7 points)

Show the worked solution
kubectl -n observability port-forward svc/jaeger-query 16686:16686 &

# 1. the slowest traces for one operation in the last hour
curl -s "http://localhost:16686/api/traces?service=checkout&operation=%2Fcheckout&lookback=1h&minDuration=2s&limit=20" \
  | jq -r '.data[] | [.traceID, (.spans | length), ([.spans[].duration] | max / 1000)] | @tsv'

# 2a. ONE SLOW SPAN? sort the spans of a single trace by duration
TRACE=<traceID-from-above>
curl -s "http://localhost:16686/api/traces/$TRACE" \
  | jq -r '.data[0].spans[] | [(.duration/1000|floor), .operationName] | @tsv' \
  | sort -nr | head -20

# 2b. MANY REPEATED SPANS? count operation names in the same trace
curl -s "http://localhost:16686/api/traces/$TRACE" \
  | jq -r '.data[0].spans[].operationName' | sort | uniq -c | sort -nr | head

#    -> 124  db.query   ... each ~15ms, none individually slow. An N+1.
#    which service owns them:
curl -s "http://localhost:16686/api/traces/$TRACE" \
  | jq -r '.data[0].spans[] | select(.operationName=="db.query")
           | .tags[] | select(.key=="db.statement") | .value' | sort -u | head
# 3. the guardrail: alert when the per-request fan-out ratio regresses.
#    traces_span_metrics_calls_total comes from the collector's spanmetrics connector.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: checkout-fanout
  namespace: monitoring
  labels:
    release: kube-prometheus-stack
spec:
  groups:
    - name: checkout-fanout
      rules:
        - alert: CheckoutDatabaseFanoutRegression
          expr: |
            sum(rate(traces_span_metrics_calls_total{service_name="checkout",span_name="db.query"}[10m]))
            /
            sum(rate(traces_span_metrics_calls_total{service_name="checkout",span_name="/checkout"}[10m]))
            > 10
          for: 10m
          labels:
            severity: warning
            team: checkout
          annotations:
            summary: "checkout issues more than 10 database calls per request"
            description: "A fan-out regression: individual calls are fast, the count per request is not."
# after the fix (batch the per-line-item query into one IN (...) query):
curl -s "http://localhost:16686/api/traces?service=checkout&operation=%2Fcheckout&lookback=15m&minDuration=2s" \
  | jq '.data | length'          # 0
curl -s "http://localhost:16686/api/traces?service=checkout&operation=%2Fcheckout&lookback=15m&limit=1" \
  | jq '[.data[0].spans[] | select(.operationName=="db.query")] | length'   # single digits

Why: a trace answers the one question dashboards structurally cannot — “where did this particular request’s time go”. Per-service medians hid this regression completely because no individual database call got slower; only their count per request changed, and averaging over all requests dilutes that into invisibility. Two shapes are worth recognising instantly: one long span (a slow dependency, lock contention, a cold cache) versus many short identical spans (an N+1 fan-out, nearly always a loop over an ORM relation). Sorting spans by duration finds the first; counting operation names finds the second — and they need different fixes, so identify which one you have before proposing anything. minDuration is the fastest way to reach the tail without exporting everything. The guardrail matters as much as the fix: fan-out ratio is a property the platform can watch for every service, which turns “someone noticed p99 got worse” into an alert that names the cause. See Jaeger and OpenTelemetry.

T13 · A new service is never scraped, and nobody knows why

The payments team added a /metrics endpoint and a ServiceMonitor a week ago. Their Grafana panels are empty, their alerts have never fired, and they have concluded that “Prometheus is broken”. The ServiceMonitor object exists and the pods serve metrics correctly when you curl them directly.

Your task:

  1. Walk the scrape chain in a fixed order and determine, from evidence, at which link it breaks.
  2. Fix the smallest thing that is actually wrong.
  3. Prove the target is up and a metric from it is queryable.

Done when: curl -s localhost:9090/api/v1/targets | jq '[.data.activeTargets[] | select(.labels.job=="payments")] | length' is at least 1 with health: "up", and curl -s --get localhost:9090/api/v1/query --data-urlencode 'query=up{job="payments"}' | jq -r '.data.result[0].value[1]' returns 1. (6 points)

Show the worked solution
# THE CHAIN, IN ORDER. Do not skip a link; each one fails silently.

# 1. is the pod serving, and does the SERVICE name that port?
kubectl -n payments exec deploy/payments -- wget -qO- localhost:9102/metrics | head -3
kubectl -n payments get svc payments -o jsonpath='{.spec.ports[*].name}{"\n"}'
kubectl -n payments get svc payments -o jsonpath='{.metadata.labels}{"\n"}'
kubectl -n payments get endpointslices -l kubernetes.io/service-name=payments

# 2. does the ServiceMonitor select that SERVICE (not the pods) and name the PORT NAME?
kubectl -n payments get servicemonitor payments -o yaml

# 3. does the Prometheus CR select that ServiceMonitor, in that namespace?
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorSelector}{"\n"}'
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorNamespaceSelector}{"\n"}'

# 4. can Prometheus's ServiceAccount even list endpoints there?
kubectl auth can-i list endpoints -n payments \
  --as=system:serviceaccount:monitoring:kube-prometheus-stack-prometheus

# 5. ground truth — what config did the operator actually generate?
kubectl -n monitoring get secret prometheus-kube-prometheus-stack-prometheus \
  -o jsonpath='{.data.prometheus\.yaml\.gz}' | base64 -d | gunzip | grep -A6 payments
# the fixed ServiceMonitor — three corrections, all of them common
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: payments
  namespace: payments
  labels:
    release: kube-prometheus-stack     # (a) matched by the Prometheus ruleSelector-equivalent
spec:                                  #     spec.serviceMonitorSelector
  selector:
    matchLabels:
      app.kubernetes.io/name: payments # (b) must match the SERVICE's labels, not the pods'
  namespaceSelector:
    matchNames: [payments]
  endpoints:
    - port: metrics                    # (c) the Service PORT NAME, never a number
      path: /metrics
      interval: 30s
# if the Prometheus CR was only watching its own namespace:
kubectl -n monitoring patch prometheus kube-prometheus-stack-prometheus --type=merge -p \
  '{"spec":{"serviceMonitorNamespaceSelector":{},"serviceMonitorSelector":{"matchLabels":{"release":"kube-prometheus-stack"}}}}'

kubectl apply -f servicemonitor.yaml
kubectl -n monitoring port-forward svc/prometheus-operated 9090:9090 &

curl -s localhost:9090/api/v1/targets \
  | jq -r '.data.activeTargets[] | select(.labels.job=="payments") | [.health, .scrapeUrl, .lastError] | @tsv'
curl -s --get localhost:9090/api/v1/query \
  --data-urlencode 'query=up{job="payments"}' | jq -r '.data.result[0].value[1]'

Why: four independent things must line up, and every one of them fails without an error message anywhere. The ServiceMonitor’s selector matches Service labels, not pod labels — the single most common mistake, and the one that produces exactly this “object exists, nothing happens” symptom. endpoints[].port is the Service port name, not a number (use targetPort only when the port is unnamed). The Prometheus CR then decides which ServiceMonitors it adopts at all: serviceMonitorSelector on a Helm-installed stack almost always requires a release: label, and for serviceMonitorNamespaceSelector an empty {} means “every namespace” while an absent one means “only my own”. Finally Prometheus needs RBAC to list services, endpoints and pods in the target namespace. When two of these look right and it still does not work, read the generated config out of the operator’s Secret — that is ground truth, and it ends the argument. The general method is in triaging delivery and the troubleshooting playbook.

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

☺ Like you’re 10: These three are about the room the work happens in: what kind of chairs you are allowed to buy and how many, how much of the table each team may take, and how to give every long job its own desk so nobody gets swept away mid-sentence.

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

T14 · Constrain what Karpenter is allowed to buy

Karpenter is provisioning very large on-demand instances for a batch queue that tolerates interruption perfectly well, there is no ceiling on how much it may provision (a bad replicas value cost four figures last month), and consolidation keeps repacking nodes at 09:15 on weekday mornings, restarting platform components in the middle of standup.

Your task:

  1. Write a NodePool that provisions only spot capacity, from a restricted set of instance families and sizes, with hard CPU and memory limits and weekly node expiry.
  2. Taint the pool so only batch workloads land on it, and add the matching toleration and node selector to the batch Deployment.
  3. Add disruption budgets so consolidation never touches more than 10% of nodes, and nothing at all on weekday working hours.

Done when: kubectl get nodepool batch -o jsonpath='{.spec.limits}' shows the caps and {.spec.disruption.budgets} shows both budgets, and after scaling the batch Deployment, kubectl get nodes -L karpenter.sh/capacity-type,node.kubernetes.io/instance-type shows a new node with capacity-type=spot and an instance type from your allowlist, hosting the batch pods. (5 points)

Show the worked solution
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: batch
spec:
  template:
    metadata:
      labels:
        workload-class: batch
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      expireAfter: 168h                 # replace nodes weekly: this is your patching story
      taints:
        - key: workload-class
          value: batch
          effect: NoSchedule
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot"]
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["c6i", "c6g", "m6i", "m6g"]
        - key: karpenter.k8s.aws/instance-size
          operator: NotIn
          values: ["nano", "micro", "small"]
  limits:                                # the hard ceiling for the whole pool
    cpu: "200"
    memory: 800Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 5m
    budgets:
      - nodes: "10%"                     # never churn more than a tenth at once
      - nodes: "0"                       # and nothing during weekday working hours
        schedule: "0 9 * * mon-fri"
        duration: 9h
# the workload has to opt in: the taint keeps everything else off the pool
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cruncher
  namespace: batch
spec:
  replicas: 30
  selector:
    matchLabels: { app: cruncher }
  template:
    metadata:
      labels: { app: cruncher }
    spec:
      nodeSelector:
        workload-class: batch
      tolerations:
        - key: workload-class
          operator: Equal
          value: batch
          effect: NoSchedule
      containers:
        - name: cruncher
          image: ghcr.io/acme/cruncher:2.2.0
          resources:
            requests: { cpu: "2", memory: 4Gi }
kubectl apply -f nodepool.yaml
kubectl get nodepool batch -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'
kubectl get nodepool batch -o jsonpath='{.spec.limits}{"\n"}{.spec.disruption.budgets}{"\n"}'

kubectl apply -f cruncher.yaml
kubectl get nodeclaims -w
kubectl get nodes -L karpenter.sh/capacity-type,node.kubernetes.io/instance-type,workload-class
kubectl -n kube-system logs -l app.kubernetes.io/name=karpenter --tail=50 | grep -i launch

Why: a NodePool is a constraint set, not a node group — Karpenter intersects the pending pods’ requirements with the pool’s and then buys the cheapest instance that satisfies both, which means the pool’s real job is to bound that search. capacity-type: spot says this work is interruptible; an instance-family allowlist keeps the bill predictable and the AMI story simple; limits is the hard ceiling that turns a runaway replicas: 5000 from an invoice into a batch of Pending pods. The taint is not optional decoration — without it, anything unschedulable will happily land on your interruptible spot nodes, and you will discover this when a spot reclamation takes the ingress controller with it. Disruption budgets are the piece people meet during an incident rather than in design: consolidation is continuously trying to repack the cluster, and a nodes: "0" window is how you say “not during business hours”. expireAfter is how nodes stay patched without a maintenance project. See Karpenter.

T15 · Carve a batch sub-budget out of a tenant’s quota

Team A filled the cluster with best-effort batch pods and then could not scale their own production Deployment. Their pods request 10 m of CPU and set a 4-core limit, so the scheduler believes the node is empty right up until it saturates. They have also filed a ticket saying pods are now rejected with “must specify limits.cpu” and they do not know why.

Your task:

  1. Cap the tenant with a ResourceQuota for total compute and object counts, plus a second, much smaller quota scoped to the batch-background PriorityClass.
  2. Add a LimitRange supplying defaults (which fixes their ticket) and a maxLimitRequestRatio so a limit can never exceed 4× its request.
  3. Prove a bare pod is admitted with injected defaults, an over-committed pod is rejected, and the batch quota is enforced independently of the main one.

Done when: kubectl -n team-a describe quota shows both quotas with used/hard, a pod created with no resources block comes back with the LimitRange defaults in kubectl -n team-a get pod defaults -o jsonpath='{.spec.containers[0].resources}', and applying a pod requesting 100m with a limit of 2 is rejected with a maxLimitRequestRatio message. (5 points)

Show the worked solution
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-a-compute
  namespace: team-a
spec:
  hard:
    requests.cpu: "20"
    requests.memory: 40Gi
    limits.cpu: "40"
    limits.memory: 80Gi
    count/deployments.apps: "30"
    persistentvolumeclaims: "10"
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-a-batch
  namespace: team-a
spec:
  hard:
    pods: "20"
    requests.cpu: "4"
    requests.memory: 8Gi
  scopeSelector:                 # only pods with this PriorityClass count here
    matchExpressions:
      - scopeName: PriorityClass
        operator: In
        values: ["batch-background"]
---
apiVersion: v1
kind: LimitRange
metadata:
  name: team-a-defaults
  namespace: team-a
spec:
  limits:
    - type: Container
      default:                   # becomes limits when the pod omits them
        cpu: 500m
        memory: 512Mi
      defaultRequest:            # becomes requests when the pod omits them
        cpu: 100m
        memory: 128Mi
      min:
        cpu: 10m
        memory: 32Mi
      max:
        cpu: "4"
        memory: 8Gi
      maxLimitRequestRatio:      # a limit may not exceed 4x its request
        cpu: "4"
        memory: "4"
# wide-pod.yaml — deliberately over-committed: ratio 2000m / 100m = 20
apiVersion: v1
kind: Pod
metadata:
  name: wide
  namespace: team-a
spec:
  containers:
    - name: app
      image: nginx:1.27-alpine
      resources:
        requests: { cpu: 100m }
        limits:   { cpu: "2" }
kubectl apply -f tenant-limits.yaml
kubectl -n team-a describe quota
kubectl -n team-a describe limitrange team-a-defaults

# defaults are injected, so the ticket goes away
kubectl -n team-a run defaults --image=nginx:1.27-alpine --restart=Never
kubectl -n team-a get pod defaults -o jsonpath='{.spec.containers[0].resources}{"\n"}'

# the over-committed pod is refused at admission
kubectl -n team-a apply -f wide-pod.yaml
# Error from server (Forbidden): ... cpu max limit to request ratio per Container is 4,
# but provided ratio is 20.000000

kubectl -n team-a get quota team-a-batch -o jsonpath='{.status.used}{"\n"}'

Why: quota and LimitRange are two different tools that only work properly as a pair, and the ticket in the scenario is the proof. The moment a ResourceQuota names requests.cpu or limits.cpu, every pod in that namespace must state that value or be rejected outright — the LimitRange is what supplies the missing numbers so developers never have to care. scopeSelector on PriorityClass is the piece that solves the actual incident: it carves a small, separate budget for interruptible work out of the tenant’s total, so batch can exhaust its own four cores while production headroom is untouched. maxLimitRequestRatio fixes the scheduling lie — the scheduler places pods on requests, so a 10 m request with a 4-core limit makes a node look empty and then saturates it; capping the ratio forces requests to resemble reality. Two properties to remember: quota is enforced at admission and is never retroactive, so existing over-budget pods keep running, and a LimitRange only mutates pods created after it exists. More in governance & compliance.

T16 · Give every long unit of work its own Job with KEDA

The video transcode worker is a Deployment autoscaled by a KEDA ScaledObject. Each item takes about twenty minutes, and scale-in regularly terminates a worker halfway through one — the message is redelivered, the item is transcoded twice, and the bill is roughly 30% higher than it should be. Between batches the queue is empty for hours.

Your task:

  1. Replace the ScaledObject with a KEDA ScaledJob whose pod processes exactly one item and exits.
  2. Keep the broker connection string out of the CR with a TriggerAuthentication, cap concurrency, and choose a scaling strategy that accounts for jobs already running.
  3. Publish work and show one Job per item appearing, each completing, with nothing killed mid-flight.

Done when: kubectl -n media get scaledjob transcode -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True; publishing five messages produces at most five Jobs (kubectl -n media get jobs) that all reach Complete; and kubectl -n media get pods shows no pod terminated by a scale-in. (5 points)

Show the worked solution
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
  name: rabbitmq-auth
  namespace: media
spec:
  secretTargetRef:
    - parameter: host              # the parameter the rabbitmq scaler expects
      name: rabbitmq-conn          # an existing Secret; never inline the URL
      key: host
---
apiVersion: keda.sh/v1alpha1
kind: ScaledJob
metadata:
  name: transcode
  namespace: media
spec:
  jobTargetRef:
    parallelism: 1
    completions: 1
    backoffLimit: 2
    template:
      spec:
        restartPolicy: Never
        containers:
          - name: worker
            image: ghcr.io/acme/transcode:1.8.0
            args: ["--once"]        # process exactly one item, then exit
            resources:
              requests: { cpu: "1", memory: 2Gi }
  pollingInterval: 15
  maxReplicaCount: 20               # concurrency cap
  successfulJobsHistoryLimit: 5
  failedJobsHistoryLimit: 5
  rollout:
    strategy: gradual               # spec changes do not nuke running Jobs
  scalingStrategy:
    strategy: accurate              # subtract in-flight Jobs from the queue length
  triggers:
    - type: rabbitmq
      metadata:
        protocol: amqp
        queueName: transcode
        mode: QueueLength
        value: "1"                  # one Job per queued message
      authenticationRef:
        name: rabbitmq-auth
kubectl -n media delete scaledobject transcode --ignore-not-found
kubectl apply -f scaledjob.yaml

kubectl -n media get scaledjob transcode
kubectl -n media get scaledjob transcode \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'

# publish five messages, then watch
kubectl -n media get jobs -w
kubectl -n media get jobs -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.succeeded}{"\n"}{end}'
kubectl -n media get pods
kubectl -n media describe scaledjob transcode | sed -n '/Events/,$p'

Why: the two KEDA objects answer different questions and the exam likes the distinction. A ScaledObject scales a long-lived workload whose pods are interchangeable and disposable — which is precisely wrong for a twenty-minute unit of work, because scaling in means terminating a pod that is halfway through one. A ScaledJob creates one Job per unit of work: KEDA never terminates a running Job, so “scale in” simply means creating fewer new ones, and the redelivery problem disappears without any change to the worker. scalingStrategy: accurate subtracts the Jobs already running from the reported queue length, which stops KEDA double-counting messages that are in flight but not yet acknowledged and over-provisioning on every poll. TriggerAuthentication keeps the broker credential in a Secret so the ScaledJob itself is safe to commit to Git — the same instinct as everywhere else in GitOps. Compare the autoscaling axes in KEDA and scaling & scheduling.

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

☺ Like you’re 10: These two are about house rules and house keys: stop keeping the front-door key on a sticky note, and make sure every new room automatically comes with a lock and a smoke alarm rather than waiting for someone to remember.

Only two tasks, but fifteen points — the highest value per task on the paper. Never leave these unattempted. Background: security & policy enforcement, secrets management and governance & compliance, then the security lab.

T17 · Make a production Secret a projection instead of an artefact

The payments Deployment reads its database password from a Secret a since-departed engineer created by hand fourteen months ago. The value in Vault was rotated last week, so production is now running on a stale credential; a copy of the plaintext is sitting in a Slack thread; and nobody knows which other namespaces have their own copy.

Your task:

  1. Create a ClusterSecretStore for the Vault KV v2 mount, authenticating with Kubernetes ServiceAccount auth.
  2. Create an ExternalSecret in payments that materialises the Secret on a refresh interval, composing two Vault keys into a single DATABASE_URL with a template.
  3. Prove the Secret is owned by the ExternalSecret, is recreated if deleted, and picks up a rotated value.

Done when: kubectl -n payments get externalsecret payments-db -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' prints True; kubectl -n payments get secret payments-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d shows the composed URL; kubectl -n payments delete secret payments-db is followed by the Secret reappearing; and after rotating the value in Vault a forced sync returns the new value. (8 points)

Show the worked solution
apiVersion: external-secrets.io/v1          # external-secrets.io/v1beta1 on older installs
kind: ClusterSecretStore
metadata:
  name: vault-kv
spec:
  provider:
    vault:
      server: https://vault.platform.svc:8200
      path: kv                              # the KV mount point
      version: v2
      auth:
        kubernetes:
          mountPath: kubernetes             # the Vault auth mount
          role: external-secrets            # a Vault role scoped to payments/* only
          serviceAccountRef:
            name: external-secrets
            namespace: external-secrets
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: payments-db
  namespace: payments
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault-kv
  target:
    name: payments-db
    creationPolicy: Owner                   # ESO owns it: delete it and it comes back
    deletionPolicy: Retain
    template:
      engineVersion: v2
      type: Opaque
      data:
        DATABASE_URL: "postgres://{{ .username }}:{{ .password }}@pg.payments.svc:5432/payments?sslmode=require"
  data:
    - secretKey: username
      remoteRef:
        key: payments/db
        property: username
    - secretKey: password
      remoteRef:
        key: payments/db
        property: password
kubectl apply -f vault-store.yaml
kubectl get clustersecretstore vault-kv \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}{"\n"}'

kubectl -n payments get externalsecret payments-db
kubectl -n payments get secret payments-db -o jsonpath='{.metadata.ownerReferences[0].kind}{"\n"}'
kubectl -n payments get secret payments-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo

# self-healing: ESO recreates what it owns
kubectl -n payments delete secret payments-db
kubectl -n payments get secret payments-db

# force an immediate resync instead of waiting out refreshInterval
kubectl -n payments annotate externalsecret payments-db force-sync="$(date +%s)" --overwrite
kubectl -n payments get secret payments-db -o jsonpath='{.data.DATABASE_URL}' | base64 -d; echo

kubectl -n external-secrets logs deploy/external-secrets --tail=30   # auth failures land here

Why: the fix is not “rotate the Secret”, it is “stop the Secret being a hand-made artefact”. The External Secrets Operator turns the Kubernetes Secret into a projection of a value that lives in a real secret manager: creationPolicy: Owner puts an owner reference on it so deleting it is self-healing rather than an outage, and refreshInterval means rotation propagates without a human in the loop. The template block does more work than it looks: applications usually want a composed connection string, and composing it inside ESO means the individual credentials never exist as separate keys for someone to copy into Slack. ClusterSecretStore versus namespaced SecretStore is the real multi-tenancy decision — a cluster-scoped store lets any namespace that can create an ExternalSecret read anything the store’s Vault role can reach, so scope the Vault role, not just the store. One last operational fact worth knowing: a pod that read the Secret through env will not see the new value until it restarts, whereas a Secret mounted as a volume is refreshed by the kubelet within about a minute — pair rotation with a checksum annotation or a reloader. See External Secrets and secrets management.

T18 · Ship the tenant baseline instead of describing it

Every new tenant namespace is supposed to receive the registry pull Secret and the standard platform ConfigMap that live in platform-templates. Today someone copies them by hand and forgets roughly one namespace in five. Separately, about half of tenant pods carry no resource requests at all, so the scheduler over-packs nodes and the platform team spends Mondays rebalancing.

Your task:

  1. Write a Kyverno ClusterPolicy rule that generates copies of everything labelled platform.acme.io/clone=true in platform-templates into every namespace labelled as a tenant, kept continuously in sync.
  2. Add a second rule that mutates Pods in those namespaces to carry default resource requests — without ever overriding a team that already set them.
  3. Create a tenant namespace and a bare pod, prove both rules fired, then change the source ConfigMap and show the clone follows.

Done when: kubectl -n tenant-y get secret ghcr-pull and kubectl -n tenant-y get cm platform-defaults both exist; kubectl -n tenant-y get pod bare -o jsonpath='{.spec.containers[0].resources.requests}' shows the injected requests while a pod that declared its own requests is unchanged; and editing the source ConfigMap changes the clone within a minute. (7 points)

Show the worked solution
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: tenant-bootstrap
spec:
  background: true                       # also applies to namespaces that already exist
  rules:
    - name: clone-platform-templates
      match:
        any:
          - resources:
              kinds: [Namespace]
              selector:
                matchLabels:
                  platform.acme.io/tenant: "?*"
      generate:
        namespace: "{{request.object.metadata.name}}"
        synchronize: true                # updates to the source flow to every clone
        cloneList:
          namespace: platform-templates
          kinds:
            - v1/Secret
            - v1/ConfigMap
          selector:
            matchLabels:
              platform.acme.io/clone: "true"

    - name: default-resource-requests
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaceSelector:
                matchLabels:
                  platform.acme.io/tenant: "?*"
      mutate:
        patchStrategicMerge:
          spec:
            containers:
              - (name): "*"              # conditional anchor: every container
                resources:
                  requests:
                    +(cpu): "100m"       # add anchor: only when the key is absent
                    +(memory): "128Mi"
# the single source of truth, maintained in one place
kubectl create ns platform-templates
kubectl -n platform-templates create secret docker-registry ghcr-pull \
  --docker-server=ghcr.io --docker-username=acme-ci --docker-password="$GHCR_TOKEN"
kubectl -n platform-templates create cm platform-defaults --from-literal=REGION=eu-west-1
kubectl -n platform-templates label secret ghcr-pull      platform.acme.io/clone=true
kubectl -n platform-templates label cm     platform-defaults platform.acme.io/clone=true

kubectl apply -f tenant-bootstrap.yaml
kubectl get clusterpolicy tenant-bootstrap          # READY True before you test anything

kubectl create ns tenant-y
kubectl label ns tenant-y platform.acme.io/tenant=acme-y
kubectl -n tenant-y get secret,cm

kubectl -n tenant-y run bare --image=nginx:1.27-alpine --restart=Never
kubectl -n tenant-y get pod bare -o jsonpath='{.spec.containers[0].resources.requests}{"\n"}'

# the source is live, not a snapshot
kubectl -n platform-templates patch cm platform-defaults -p '{"data":{"REGION":"eu-central-1"}}'
sleep 30 && kubectl -n tenant-y get cm platform-defaults -o jsonpath='{.data.REGION}{"\n"}'

kubectl get policyreport -A                          # what fired, and where

Why: Kyverno’s three rule types answer three different questions — validate rejects bad input, mutate corrects it on the way in, and generate creates things the user never asked for — and only the last two turn policy from a gate into a golden path. cloneList is the difference between describing a baseline and shipping it: you maintain one source object in one namespace, Kyverno propagates it to every tenant, and synchronize: true makes those copies both self-healing and live, so updating the source updates every clone. The mutate rule is the other half: rather than rejecting a pod with no requests and making a developer read a policy error, you fill in a sane default and let them ship. The anchors are what make that safe — (name): "*" is a conditional anchor that matches every container in the list, and +(cpu) adds the key only when it is absent, so a team that deliberately set its own requests is never silently overridden. Two things to check when a generate rule appears to do nothing at all: Kyverno’s background controller needs RBAC for every kind it clones (Secrets are commonly missing), and the source namespace must not be caught by the installation’s namespace exclusion list. See Kyverno.

⚠ The two habits that cost most in this domain

First, rolling out an enforcing policy without measuring first. Both tasks above have a measure step available — a --dry-run=server apply, or Kyverno’s Audit validation action and its policy reports — and skipping it is how a policy rollout becomes an incident at 09:00. Second, testing a policy as yourself. On your practice cluster you are almost certainly cluster-admin, and admin bypasses more than you expect: Kyverno installations routinely exempt system namespaces and service accounts, and a ValidatingAdmissionPolicy with no binding does nothing at all while looking perfectly healthy. Always verify from the identity that will actually be affected — kubectl auth can-i --as for RBAC, a real tenant namespace for admission.

Score yourself

☺ Like you’re 10: Add up your points, turn it into a percentage, and then do the more useful bit — look at which questions you lost points on and go back to those.

Mark after a break. Marking your own work while still adrenalised produces generous nonsense. Award full points only when the done-when check actually passed on the cluster, half when the resource exists and is broadly right but the check did not pass, and zero when you did not attempt it. There is no credit for “I knew how to do that one.”

TaskDomainPointsYour score
T1 · Server-side apply and a managed namespaceGitOps5
T2 · Ignore a second controller’s owned fieldsGitOps5
T3 · Tekton Triggers from a signed webhookGitOps5
T4 · Kaniko build with no privileged containerGitOps5
T5 · Blue-green preview at reduced scale, analysis-gatedGitOps5
T6 · Recover objects stuck behind an orphaned finalizerPlatform APIs5
T7 · Stop a controller reconcile hot loopPlatform APIs5
T8 · Crossplane claim triage down the chainPlatform APIs5
T9 · Backstage template with validated parametersPlatform APIs5
T10 · ValidatingAdmissionPolicy on your own APIPlatform APIs5
T11 · Recording rules and tested burn-rate alertsObservability7
T12 · Jaeger trace analysis of an N+1 regressionObservability7
T13 · Triage a ServiceMonitor that never scrapesObservability6
T14 · Karpenter NodePool constraints and budgetsArchitecture5
T15 · Priority-scoped quota plus LimitRange ratioArchitecture5
T16 · KEDA ScaledJob for long units of workArchitecture5
T17 · External Secrets with templating and rotationSecurity8
T18 · Kyverno cloneList generate plus safe mutateSecurity7
TotalAll five domains100

Computing your result. The points total 100, so your raw score is your percentage, and the bar is 64%. That leaves 36 points spendable — you can drop six of the five-point tasks outright and still clear the bar with six to spare. Then do the more useful arithmetic and total each domain separately: a 70 built from five even domains and a 70 built from four strong domains plus a zero are completely different results, and the second one fails a real exam the first day the task draw is unkind.

DomainAvailableYoursIf you scored under two-thirds, go here
GitOps & Continuous Delivery25GitOps workflows, CI/CD & progressive delivery, then re-drill the GitOps bank.
Platform APIs & Self-Service25Platform APIs & operators, self-service, then the Platform APIs bank.
Observability & Operations20Observability, reliability & incidents, then the Observability bank.
Platform Architecture & Infrastructure15Platform architecture, scaling & scheduling, then the Architecture bank.
Security & Policy Enforcement15Security & policy, secrets management, 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 redo the task cold two days later. A task where you knew the shape but burned the clock hunting field names is a speed problem, and the fix is the command reference plus kubectl explain drills, not more reading. A task that broke in a way you could not diagnose is a triage gap: work the troubleshooting playbook and triaging workloads until the evidence order is automatic.

What your score means

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

Treat the number as a diagnostic, never as a prediction. A mock is harsher in some ways (no Quick Reference box tailored to each task, tasks written by someone who cannot see your cluster) and gentler in others (your own cluster, your own aliases, no exam-day nerves and no proctor watching your desk). Read the band, not the decimal.

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

One pattern is 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 — and pacing is the cheapest thing on this entire list to fix. Sit the paper again in a fortnight with one hard rule: you touch every task once before you return to any of them.

🎬 At the Platform Guild
🦆

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

🐢

Timmy: Which tasks were the zeros?

🦆

Dot: T3, T4 and T11. The Triggers one had four objects and I ran out of patience, Kaniko I have genuinely never done, and the burn-rate maths made my eyes slide off the page.

🦫

Benny: Those are three different problems, Dot, and only one of them is “study harder”. T3 is typing speed under a clock. T4 is a gap. T11 is arithmetic you can memorise in ten minutes — 14.4 pages, 6 tickets, done.

👺

Gizmo: Or just skip the analysis template! Promote straight to prod, watch the graph, revert if it goes red. Same outcome, half the YAML. 🤑

🦉

Professor Owl: “Watch the graph” is a person, Gizmo. The whole point of prePromotionAnalysis is that the measurement happens even at 03:00 when nobody is watching anything.

🐿️

Nutty: Wait — I got T4 in four minutes because I had built a Kaniko Task at work last year. And I scored zero on T8 because I have never touched Crossplane. It is the same paper and we are strong in opposite places.

🐢

Timmy: Which is exactly why you both re-sit in a fortnight. Dot, start at T11. Nutty, start at T8. Same paper, different order, and neither of you will run out of clock on the domain you are worst at.

🐢 Timmy’s checkpoint

1. Which documentation may you have open during the real exam, and name three popular projects whose own docs are not permitted. 2. Why does a large CRD fail to sync with client-side apply, and which sync option fixes it? 3. An autoscaler and a CA injector keep an app permanently OutOfSync — which two Argo CD ignore mechanisms fit which case, and which sync option must accompany them? 4. Your custom resource has been Terminating for six days. What is actually blocking it, and why is --force no help? 5. In a burn-rate alert pair, what does the long window buy you and what does the short window buy you? 6. Name three independent reasons a correct-looking ServiceMonitor produces no targets. 7. When should you reach for a KEDA ScaledJob instead of a ScaledObject?

Check your answers
  1. kubernetes.io/docs, kubernetes.io/blog, task-specific documentation linked from the exam’s Quick Reference box, and the local man / /usr/share documentation on the exam desktop. Not permitted: Argo CD, Flux, Tekton, Crossplane, Backstage, Prometheus, OpenTelemetry, Kyverno, Gatekeeper, Istio and Helm docs (any three). Your substitute is the live API — kubectl explain --recursive, kubectl api-resources, and reading an existing object.
  2. Client-side apply stores the entire manifest in the kubectl.kubernetes.io/last-applied-configuration annotation, and all annotations on an object are capped at 256 KiB. ServerSideApply=true moves ownership tracking into metadata.managedFields on the server, so no annotation is written.
  3. ignoreDifferences.managedFieldsManagers for the autoscaler — it drops everything kube-controller-manager owns without enumerating a single field. jqPathExpressions for the CA bundle — it reaches an array element selected by name rather than by index. Note the limit of the first one: a mutating webhook’s patch is applied inside the original request and is attributed to the requester, so it never gets a manager entry of its own. Either rule must be paired with the RespectIgnoreDifferences=true sync option, or automated self-heal re-applies anyway.
  4. A string left in metadata.finalizers by a controller that no longer exists. Deletion only sets deletionTimestamp; the API server will not remove the object while the list is non-empty, and --force --grace-period=0 does not bypass finalizers. Remove the specific finalizer entry once you have confirmed the external resource it guarded is gone.
  5. The long window (1h, 6h) gives significance, so a brief blip cannot page you. The short window (5m, 30m) gives fast reset, so the alert clears promptly once the burn stops instead of lingering for the length of the long window.
  6. Any three of: the selector matches pod labels instead of Service labels; endpoints[].port uses a number instead of the Service port name; the Prometheus CR’s serviceMonitorSelector does not match the ServiceMonitor’s labels; serviceMonitorNamespaceSelector is absent so only the Prometheus’s own namespace is watched; Prometheus’s ServiceAccount lacks RBAC to list endpoints in that namespace.
  7. When each unit of work is long-running and must not be interrupted. A ScaledObject scales a Deployment whose pods are disposable, so scale-in kills work in progress; a ScaledJob creates one Job per item and KEDA never terminates a running Job — scaling in just means creating fewer new ones.

That is the whole paper. Score it, write the domain breakdown somewhere you will actually see it, and let your two weakest numbers choose the next fortnight. When those numbers stop moving, the remaining work is logistical rather than technical: the exam guide covers booking, the environment and the rules; the field notes collect what candidates reported from the day itself; the final checklist is the last thing to read before you sit down; and the command reference, what to know cold and the glossary are the three pages worth a last skim on the morning. Go again with Set 1 for pacing or Set 2 for another cold read, drill a single domain in the bank, or head back to the course home to pick a lesson.

⏱ The six papers

Set 1 · Set 2 · Set 3 · Set 4 (you are here) · Set 5 · Set 6. Next up: Set 5. All six are weighted to the official blueprint and worth 100 points each; see the practice hub for how to sit them, and the exam guide for the real thing.