cka-sim/tracks/cka/bank_hard.py
vumast3r 43e2e1db2f refactor: renumber the CKA bank 1-102 with no gaps
Task ids used to jump 1-43 / 101-124 / 401-418 / 501-517. They now run
contiguously in the same order:

  1-43    unchanged (base and extra)
  44-67   hard pool          (was 101-124)
  68-85   Simulator Set C    (was 401-418)
  86-102  Simulator Set D    (was 501-517)

Every reference moved with its task: ids, solution keys, workspace paths
({WS}/qNN and the graders' hidden .qNN-* files), temp paths, the one
hardcoded id in code (Q56's derived canary weight), and Q-number
cross-references in the harness, README and the explainer. Each task's
paths were checked to carry only its own new number. History entries and
the local session state were remapped so past attempts stay attached to the
same tasks; retired set A/B ids keep their old numbers and match nothing.
weak-spots is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mhxm4rsUfuE5HoytYbu2YW
2026-09-22 22:05:37 -06:00

830 lines
58 KiB
Python

"""Hard-difficulty CKA tasks (ids 44-67): multi-part, no hints, traps, exact output,
compound failures, collateral-damage checks, exam-style contexts. Modelled on the kind of
difficulty people report from killer.sh, not on its content."""
from tracks.cka.common import C, NS, ns_pick, name_pick
CTX = "Use context: `kubectl config use-context %{ctx}`\n\n"
def ctx_pick(r):
return r.choice(["k8s-c1-H", "k8s-c2-AC", "k8s-c3-CCC"])
QUESTIONS = [
# ================================================================ ARCH ====
{
"id": 44, "domain": "ARCH", "difficulty": "hard", "title": "Exact RBAC with an existing over-grant",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "ci"), "sa": name_pick(r, "ci-bot")},
"task": CTX + """
ServiceAccount `%{sa}` in namespace `%{ns}` is used by a CI system. Its permissions must be
**exactly**:
- in `%{ns}`: `get`, `list`, `watch`, `patch` on `deployments` and `replicasets` (apps),
and `create` on `pods/exec`,
- cluster-wide: `list` on `nodes`.
Nothing more. Audit what it can do now and correct it. Use Role `%{sa}-role`, RoleBinding
`%{sa}-rb`, ClusterRole `%{sa}-nodes`, ClusterRoleBinding `%{sa}-crb`.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create sa %{sa} --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} delete role,rolebinding --all >/dev/null 2>&1
$K delete clusterrole %{sa}-nodes --ignore-not-found >/dev/null; $K delete clusterrolebinding %{sa}-crb %{sa}-legacy-admin --ignore-not-found >/dev/null
# trap: someone bound cluster-admin to this SA "temporarily"
$K create clusterrolebinding %{sa}-legacy-admin --clusterrole=cluster-admin --serviceaccount=%{ns}:%{sa} >/dev/null
""",
"checks": [
C("can get/list/watch/patch deployments and replicasets in %{ns}", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 || exit 1 # not via cluster-admin
for res in deployments.apps replicasets.apps; do for v in get list watch patch; do eq "$($K auth can-i $v $res -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes || exit 1; done; done"""),
C("can create pods/exec in %{ns}; can list nodes", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 || exit 1
eq "$($K auth can-i create pods --subresource=exec -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes && eq "$($K auth can-i list nodes --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes"""),
C("no over-grant: cannot delete deployments, cannot get secrets, cannot get nodes", 3, r"""for t in "delete deployments -n %{ns}" "get secrets -n %{ns}" "get nodes" "create pods -n default"; do eq "$($K auth can-i $t --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null || true)" no || exit 1; done"""),
C("the cluster-admin binding is gone and named objects exist", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 && $K -n %{ns} get role/%{sa}-role rolebinding/%{sa}-rb >/dev/null 2>&1 && $K get clusterrole/%{sa}-nodes clusterrolebinding/%{sa}-crb >/dev/null 2>&1"""),
],
},
{
"id": 45, "domain": "ARCH", "breaks": "etcd", "difficulty": "hard", "title": "etcd restore",
# solo: restoring rolls EVERY API object back to the snapshot - other tasks' seeds and answers too
"solo": True,
"params": lambda r: {"ctx": "k8s-c1-H", "cm": name_pick(r, "restore-proof")},
"task": CTX + """
An etcd snapshot taken earlier is at `/opt/cka/backup.db` on the control-plane node.
A ConfigMap `%{cm}` in namespace `default` existed when the snapshot was taken and has since
been deleted.
**Restore the cluster's etcd from that snapshot** so the ConfigMap exists again. The cluster
must be healthy afterwards (API server up, all kube-system pods Running).
Do not recreate the ConfigMap by hand — its data must come from the snapshot.
A restore rolls every API object back to the moment of the snapshot — on a real exam cluster that
would include the work you did on other tasks, so a restore is the last thing you do.
""",
"setup": r"""
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/backup.db'
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with ./etcd-tools.sh, then re-seed"; exit 1; }
$K -n default delete cm %{cm} --ignore-not-found >/dev/null
$K -n default create cm %{cm} --from-literal=proof=$(date +%s)-%{cm} >/dev/null
out=$(nx "$CP" 'etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key snapshot save /opt/cka/backup.db' 2>&1) \
|| { echo "snapshot save on $CP failed: $out"; exit 1; }
sleep 2; $K -n default delete cm %{cm} >/dev/null
nx "$CP" 'awk "/hostPath:/{getline; p=\$2} /name: etcd-data/{if (p) print p}" /etc/kubernetes/manifests/etcd.yaml' </dev/null > "$WS/.q45-datadir"
date +%s > "$WS/.q45-t0"
""",
"checks": [
C("ConfigMap %{cm} exists again with its original data", 6, r"""j -n default cm %{cm} '{.data.proof}' | grep -q -- '-%{cm}$'"""),
C("etcd runs on a data directory restored from the snapshot", 2, r"""
was=$(cat "$WS/.q45-datadir" 2>/dev/null); t0=$(cat "$WS/.q45-t0" 2>/dev/null); [ -n "$was" ] && [ -n "$t0" ] || exit 1
now=$(nx "$CP" 'awk "/hostPath:/{getline; p=\$2} /name: etcd-data/{if (p) print p}" /etc/kubernetes/manifests/etcd.yaml' </dev/null)
[ -n "$now" ] || exit 1
[ "$now" != "$was" ] && exit 0
# the same directory name as before is fine if it was rebuilt from the snapshot after seeding
m=$(nx "$CP" "stat -c %Y $now/member 2>/dev/null" </dev/null); [ -n "$m" ] && [ "$m" -gt "$t0" ]"""),
C("control plane healthy: etcd, apiserver, scheduler, controller-manager Running", 2, r"""j -n default cm %{cm} '{.data.proof}' | grep -q -- '-%{cm}$' || exit 1 # only once restored
for c in etcd kube-apiserver kube-scheduler kube-controller-manager; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || exit 1; done"""),
],
},
{
"id": 46, "domain": "ARCH", "breaks": ["w3-kubelet", "w3-staticpath"], "difficulty": "hard", "title": "Static pod on a relocated manifest path",
"params": lambda r: {"ctx": "k8s-c1-H", "ns": ns_pick(r, "edge"), "pod": name_pick(r, "edge-proxy")},
"task": CTX + """
Exactly one worker node is labelled `tier=edge`. On that node create a **static pod**:
- name `%{pod}`, namespace `%{ns}` (create the namespace),
- image `nginx:1.27`, requests cpu 50m / memory 32Mi, label `role=edge`.
The mirror pod must be Running. Note that the kubelet on that node may not be configured the
way you expect.
""",
"setup": NS + r"""
mkns %{ns}
for n in "$W1" "$W2" "$W3"; do $K label node $n tier- >/dev/null 2>&1 || true; done
$K label node "$W3" tier=edge --overwrite >/dev/null
nx "$W3" '
rm -f /etc/kubernetes/manifests/%{pod}*.yaml /etc/kubernetes/static/%{pod}*.yaml 2>/dev/null; mkdir -p /etc/kubernetes/static
sed -i "s|^staticPodPath: .*|staticPodPath: /etc/kubernetes/static|" /var/lib/kubelet/config.yaml
systemctl restart kubelet'
""",
"checks": [
C("mirror pod %{pod}-<edge node> Running in %{ns}", 4, r"""eq "$(j -n %{ns} pod "%{pod}-$W3" '{.status.phase}')" Running"""),
C("image, requests and label correct", 2, r"""p="$K -n %{ns} get pod %{pod}-$W3"; eq "$($p -o jsonpath='{.spec.containers[0].image}' 2>/dev/null)" nginx:1.27 && eq "$($p -o jsonpath='{.spec.containers[0].resources.requests.cpu}')" 50m && eq "$($p -o jsonpath='{.metadata.labels.role}')" edge"""),
C("manifest lives in the kubelet's actual staticPodPath", 2, r"""d=$(nx "$W3" 'grep staticPodPath /var/lib/kubelet/config.yaml' | awk '{print $2}'); nx "$W3" "grep -ls %{pod} $d/*.yaml" >/dev/null 2>&1"""),
],
},
{
"id": 47, "domain": "ARCH", "difficulty": "hard", "title": "User onboarding to a working kubeconfig",
"params": lambda r: {"ctx": "k8s-c1-H", "user": r.choice(["maya", "theo", "iris"]), "ns": ns_pick(r, "dev")},
"task": CTX + """
Onboard user `%{user}` (group `developers`) end to end:
1. Key + CSR with CN=%{user}, O=developers; Kubernetes CSR object `%{user}`, approved, certificate issued.
2. A **self-contained** kubeconfig at `{WS}/q47/%{user}.kubeconfig` (certs embedded, not file paths)
with a context `%{user}@lab` whose default namespace is `%{ns}`.
3. `%{user}` may do everything to pods, deployments and services in `%{ns}` (edit-level), nothing in other namespaces.
`kubectl --kubeconfig {WS}/q47/%{user}.kubeconfig get pods` must work with no extra flags.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q47"; mkdir -p "$WS/q47"; $K delete csr %{user} --ignore-not-found >/dev/null; $K -n %{ns} delete rolebinding --all >/dev/null 2>&1""",
"checks": [
C("CSR %{user} approved with certificate, O=developers", 2, r"""[ -n "$(j csr %{user} '{.status.certificate}')" ] && j csr %{user} '{.status.certificate}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'O *= *developers'"""),
C("kubeconfig is self-contained and works without flags", 3, r"""f="$WS/q47/%{user}.kubeconfig"; [ -f "$f" ] || exit 1; grep -q 'client-certificate-data' "$f" && grep -q 'certificate-authority-data' "$f" && ! grep -q 'client-certificate:' "$f" && kubectl --kubeconfig "$f" get pods >/dev/null 2>&1"""),
C("context %{user}@lab defaults to %{ns}", 1, r"""eq "$(kubectl --kubeconfig "$WS/q47/%{user}.kubeconfig" config view -o jsonpath='{.contexts[?(@.name=="%{user}@lab")].context.namespace}')" %{ns}"""),
C("edit rights in %{ns}, none elsewhere", 2, r"""eq "$($K auth can-i create deployments -n %{ns} --as=%{user} --as-group=developers 2>/dev/null)" yes && eq "$($K auth can-i delete pods -n %{ns} --as=%{user} --as-group=developers 2>/dev/null)" yes && eq "$($K auth can-i get pods -n default --as=%{user} --as-group=developers 2>/dev/null || true)" no && eq "$($K auth can-i get secrets -n kube-system --as=%{user} --as-group=developers 2>/dev/null || true)" no"""),
],
},
{
"id": 48, "domain": "ARCH", "difficulty": "hard", "title": "Kustomize with generators and patches",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "kz"), "rep": r.choice([3, 4]), "lvl": r.choice(["debug", "trace", "warn"])},
"task": CTX + """
Base at `{WS}/q48/base` (Deployment `web`, Service `web`). Build overlay `{WS}/q48/overlays/prod` that:
- targets namespace `%{ns}` and adds label `tier=prod` to all resources (labels only, not selectors),
- sets replicas to %{rep} via a **strategic merge patch** file `replicas.yaml`,
- adds a `configMapGenerator` named `web-config` with literal `LOG_LEVEL=%{lvl}` (hash suffix enabled),
- injects that ConfigMap into the container via `envFrom` (JSON 6902 patch file `envfrom.yaml`).
Apply with `kubectl apply -k`. The Deployment must reference the hashed ConfigMap name.
""",
"setup": r"""
rm -rf "$WS/q48"; mkdir -p "$WS/q48/base"
cat > "$WS/q48/base/kustomization.yaml" <<'EOF'
resources: [deployment.yaml, service.yaml]
EOF
cat > "$WS/q48/base/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: web}
spec:
replicas: 1
selector: {matchLabels: {app: web}}
template:
metadata: {labels: {app: web}}
spec:
containers: [{name: web, image: nginx:1.25, ports: [{containerPort: 80}]}]
EOF
cat > "$WS/q48/base/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata: {name: web}
spec: {selector: {app: web}, ports: [{port: 80}]}
EOF
$K delete ns %{ns} --ignore-not-found --wait=false >/dev/null
""",
"checks": [
C("overlay has replicas.yaml (SMP) and envfrom.yaml (JSON6902)", 1, r"""[ -f "$WS/q48/overlays/prod/replicas.yaml" ] && [ -f "$WS/q48/overlays/prod/envfrom.yaml" ] && grep -q 'op:' "$WS/q48/overlays/prod/envfrom.yaml" """),
C("deployed to %{ns}: %{rep} replicas, tier=prod label, selector untouched", 2, r"""d="$K -n %{ns} get deploy web"; eq "$($d -o jsonpath='{.spec.replicas}' 2>/dev/null)" %{rep} && eq "$($d -o jsonpath='{.metadata.labels.tier}')" prod && [ -z "$($d -o jsonpath='{.spec.selector.matchLabels.tier}')" ]"""),
C("hashed ConfigMap with LOG_LEVEL=%{lvl}, referenced via envFrom", 3, r"""cm=$(j -n %{ns} deploy web '{.spec.template.spec.containers[0].envFrom[0].configMapRef.name}'); [ -n "$cm" ] && echo "$cm" | grep -Eq '^web-config-[a-z0-9]{6,}$' && eq "$(j -n %{ns} cm "$cm" '{.data.LOG_LEVEL}')" %{lvl}"""),
C("pods available and env visible", 1, r"""eq "$(j -n %{ns} deploy web '{.status.availableReplicas}')" %{rep} && $K -n %{ns} exec deploy/web -- env 2>/dev/null | grep -q '^LOG_LEVEL=%{lvl}$'"""),
],
},
{
"id": 49, "domain": "ARCH", "difficulty": "hard", "title": "CRD with validation and defaults",
"params": lambda r: {"ctx": ctx_pick(r), "kind": r.choice(["Rollout", "Canary", "Release"]), "env": r.choice(["prod", "stage"])},
"task": CTX + """
Create CRD `%{kind}` (group `deploy.example.com`, v1, Namespaced, plural = the kind in lowercase plus
`s`, shortName = the first two letters of the kind in lowercase) with schema:
- `spec.replicas` integer, **required**, minimum 1, maximum 10,
- `spec.environment` string, enum `prod|stage|dev`, **default** `dev`,
- `spec.image` string, required.
Then:
1. Create `%{kind}` `good` in `default` with replicas 3, image `nginx:1.27`, environment `%{env}`.
2. Save the API server's rejection message for a `%{kind}` with `replicas: 0` to `{WS}/q49/rejected.txt`.
3. Create `%{kind}` `defaulted` in `default` with only replicas 2 and image set; its environment
must be `dev` after creation.
This context's default namespace is not necessarily `default` — say `-n default` explicitly.
""",
"setup": r"""rm -rf "$WS/q49"; mkdir -p "$WS/q49"; k=$(echo %{kind} | tr A-Z a-z); $K delete crd ${k}s.deploy.example.com --ignore-not-found >/dev/null""",
"checks": [
C("CRD exists, Namespaced, replicas has min/max and is required", 2, r"""s=""; k=$(echo %{kind} | tr A-Z a-z); c="$K get crd ${k}s.deploy.example.com"; eq "$($c -o jsonpath='{.spec.scope}' 2>/dev/null)" Namespaced && s=$($c -o jsonpath='{.spec.versions[0].schema.openAPIV3Schema.properties.spec}'); echo "$s" | grep -q '"minimum":1' && echo "$s" | grep -q '"maximum":10' && echo "$s" | grep -q '"required":\[' """),
C("good created with environment %{env}", 1, r"""k=$(echo %{kind} | tr A-Z a-z); eq "$(j -n default ${k}s.deploy.example.com good '{.spec.environment}')" %{env}"""),
C("replicas 0 is rejected and the message was saved", 2, r"""grep -qiE 'minimum|greater than|>= 1|invalid' "$WS/q49/rejected.txt" 2>/dev/null"""),
C("defaulting works: defaulted.spec.environment == dev", 2, r"""k=$(echo %{kind} | tr A-Z a-z); eq "$(j -n default ${k}s.deploy.example.com defaulted '{.spec.environment}')" dev"""),
],
},
{
"id": 65, "domain": "ARCH", "breaks": "certs", "difficulty": "hard", "title": "Renew control-plane certificates",
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
Security policy requires the control-plane certificates to be renewed now.
On the control-plane node, renew **all** kubeadm-managed certificates, make every control-plane
component pick up the new certificates, and update the admin kubeconfig on the node
(`/etc/kubernetes/admin.conf`) so it uses the renewed client certificate.
Write the new `apiserver` certificate's expiry (as printed by `kubeadm certs check-expiration`) to `{WS}/q65/expiry.txt`.
""",
"setup": r"""rm -rf "$WS/q65"; mkdir -p "$WS/q65"; nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' > "$WS/.q65-before" """,
"checks": [
C("apiserver certificate was renewed (newer notBefore)", 3, r"""b=$(sed 's/notBefore=//' "$WS/.q65-before"); a=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' | sed 's/notBefore=//'); [ "$(date -d "$a" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
C("all components restarted and Running with the new certs (apiserver serving cert matches file)", 3, r"""
for c in kube-apiserver kube-scheduler kube-controller-manager etcd; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || exit 1; done
srv=$(nx "$CP" 'echo | openssl s_client -connect 127.0.0.1:6443 2>/dev/null | openssl x509 -noout -serial -startdate'); f=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -serial -startdate'); [ -n "$srv" ] && eq "$srv" "$f" || exit 1
# ...and it is the RENEWED certificate: an untouched cluster also serves the file it has on disk
b=$(sed 's/notBefore=//' "$WS/.q65-before"); a=$(echo "$srv" | sed -n 's/notBefore=//p'); [ "$(date -d "$a" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
C("admin.conf uses the renewed client cert", 2, r"""nx "$CP" 'grep client-certificate-data /etc/kubernetes/admin.conf | awk "{print \$2}" | base64 -d | openssl x509 -noout -startdate' | sed 's/notBefore=//' > /tmp/q65a; b=$(sed 's/notBefore=//' "$WS/.q65-before"); [ "$(date -d "$(cat /tmp/q65a)" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
C("expiry.txt matches kubeadm's report for apiserver", 1, r"""exp=$(nx "$CP" 'kubeadm certs check-expiration 2>/dev/null' | awk '$1=="apiserver"{print $2,$3,$4,$5,$6}'); [ -n "$exp" ] && grep -qF "$exp" "$WS/q65/expiry.txt" 2>/dev/null"""),
],
},
{
"id": 67, "domain": "ARCH", "difficulty": "hard", "title": "Prepare a node join without shell access to the control plane",
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
A new worker node is about to join this cluster, but you can only hand the networking team a
**file** — no shell access to the control plane.
1. On the control-plane node, create a **new** bootstrap token with TTL `2h`.
2. Compute the CA certificate's discovery hash **yourself** with openssl — the same
SHA-256-of-the-SubjectPublicKeyInfo value `kubeadm join --discovery-token-ca-cert-hash`
checks against. Do not just copy whatever `kubeadm token create --print-join-command` prints.
3. Determine the API server's real address as seen **from inside the cluster network** — not the
`127.0.0.1:<port>` your local kubeconfig uses to reach it from this machine, which a joining
node cannot use.
4. Write exactly these three lines to `{WS}/q67/node-join.env`:
```
JOIN_TOKEN=<token>
CA_HASH=sha256:<hash>
API_ENDPOINT=<ip>:<port>
```
Every value must be genuine: plugged into `kubeadm join $API_ENDPOINT --token $JOIN_TOKEN
--discovery-token-ca-cert-hash $CA_HASH` right now, it would work.
""",
"setup": r"""
rm -rf "$WS/q67"; mkdir -p "$WS/q67"
for t in $($K -n kube-system get secrets -o jsonpath='{.items[?(@.type=="bootstrap.kubernetes.io/token")].metadata.name}'); do $K -n kube-system delete secret $t >/dev/null; done
""",
"checks": [
C("a real, unexpired bootstrap token", 2, r"""
tok=$(grep -oE '^JOIN_TOKEN=[a-z0-9]{6}\.[a-z0-9]{16}' "$WS/q67/node-join.env" 2>/dev/null | cut -d= -f2); [ -n "$tok" ] || exit 1
id=${tok%%.*}; exp=$(j -n kube-system secret bootstrap-token-$id '{.data.expiration}' | base64 -d 2>/dev/null); [ -n "$exp" ] || exit 1
[ "$(date -d "$exp" +%s)" -gt "$(date +%s)" ]"""),
C("CA_HASH computed correctly (matches the real CA's SPKI SHA-256)", 3, r"""
want=$(nx "$CP" "openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex | sed 's/^.* //'")
got=$(grep -oE '^CA_HASH=sha256:[0-9a-f]{64}' "$WS/q67/node-join.env" 2>/dev/null | cut -d= -f2 | cut -d: -f2)
[ -n "$want" ] && eq "$got" "$want" """),
C("API_ENDPOINT is the real in-cluster address, not the local kubeconfig's", 2, r"""
want=$($K get endpointslices -l kubernetes.io/service-name=kubernetes -o jsonpath='{.items[0].endpoints[0].addresses[0]}:{.items[0].ports[0].port}' 2>/dev/null)
got=$(grep -oE '^API_ENDPOINT=.*' "$WS/q67/node-join.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
[ -n "$want" ] && eq "$got" "$want" """),
],
},
# ================================================================ WORK ====
{
"id": 50, "domain": "WORK", "breaks": "w1-taint", "difficulty": "hard", "title": "Spread, tolerate, prioritise — under a quota",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "spread"), "dep": name_pick(r, "gateway"), "key": r.choice(["pool", "lane"]), "val": r.choice(["ingress", "edge"])},
"task": CTX + """
Node `{W1}` is tainted `%{key}=%{val}:NoSchedule`. In namespace `%{ns}` create Deployment `%{dep}`
(nginx:1.27, **3 replicas**) such that:
- exactly one pod runs on each worker node (required pod anti-affinity on `kubernetes.io/hostname`),
- it tolerates the taint on `{W1}`,
- it uses PriorityClass `%{dep}-prio` (value 50000, create it),
- requests cpu 100m / memory 64Mi.
All 3 pods must be Running. The namespace has a ResourceQuota — respect it or adjust it, but the
quota must still cap pods in the namespace.
""",
"setup": NS + r"""
mkns %{ns}
$K taint node "$W1" %{key}- >/dev/null 2>&1 || true; $K taint node "$W1" %{key}=%{val}:NoSchedule >/dev/null
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null; $K delete priorityclass %{dep}-prio --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: ResourceQuota
metadata: {name: %{ns}-quota, namespace: %{ns}}
spec: {hard: {pods: "2", requests.cpu: "250m"}}
EOF
""",
"checks": [
C("3 pods available, one per worker", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 && [ "$(j -n %{ns} pod -l app=%{dep} '{.items[*].spec.nodeName}' | tr ' ' '\n' | sort -u | wc -l)" = 3 ]"""),
C("required anti-affinity on hostname + toleration for %{key}", 2, r"""t=$(j -n %{ns} deploy %{dep} '{.spec.template.spec}'); echo "$t" | grep -q 'requiredDuringSchedulingIgnoredDuringExecution' && echo "$t" | grep -q '"topologyKey":"kubernetes.io/hostname"' && echo "$t" | grep -q '"key":"%{key}"' """),
C("PriorityClass %{dep}-prio (50000) in use", 1, r"""eq "$(j priorityclass %{dep}-prio '{.value}')" 50000 && eq "$(j -n %{ns} pod -l app=%{dep} '{.items[0].spec.priority}')" 50000"""),
C("quota still exists and caps pods (>=3)", 2, r"""p=$(j -n %{ns} quota %{ns}-quota '{.spec.hard.pods}'); [ -n "$p" ] && [ "$p" -ge 3 ]"""),
],
},
{
"id": 51, "domain": "WORK", "difficulty": "hard", "title": "Rollback to a specific revision, then pause",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "rel"), "dep": name_pick(r, "portal")},
"task": CTX + """
Deployment `%{dep}` in `%{ns}` has gone through several releases; the current one is broken.
1. Roll it back to the revision that used image `nginx:1.25` (not simply the previous revision).
2. Scale it to 3 replicas and wait until available.
3. **Pause** the rollout, then change the image to `nginx:1.27` and add env `RELEASE=next`.
Because it is paused, no new ReplicaSet may be created and the running pods must stay on `nginx:1.25`.
Leave it paused.
""",
"setup": NS + r"""
mkns %{ns}; $K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.25 >/dev/null
$K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
$K -n %{ns} set image deploy %{dep} nginx=nginx:1.26 >/dev/null; $K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
$K -n %{ns} set image deploy %{dep} nginx=nginx:1.27 >/dev/null; $K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
$K -n %{ns} set image deploy %{dep} nginx=nginx:9.99-broken >/dev/null
""",
"checks": [
C("running pods use nginx:1.25 and 3 are available", 3, r"""
for t in $(seq 1 15); do # pods of the replaced ReplicaSets linger for a moment while they terminate
ok=1; eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 || ok=0
for i in $(j -n %{ns} pod -l app=%{dep} '{.items[*].spec.containers[0].image}'); do eq "$i" nginx:1.25 || ok=0; done
[ $ok = 1 ] && exit 0; sleep 2
done; exit 1"""),
C("deployment is paused with template image nginx:1.27 and RELEASE=next", 2, r"""d="$K -n %{ns} get deploy %{dep}"; eq "$($d -o jsonpath='{.spec.paused}' 2>/dev/null)" true && eq "$($d -o jsonpath='{.spec.template.spec.containers[0].image}')" nginx:1.27 && eq "$($d -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="RELEASE")].value}')" next"""),
C("no ReplicaSet exists for the paused template (1.27 + RELEASE)", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].image}')" nginx:1.27 || exit 1
! $K -n %{ns} get rs -o json 2>/dev/null | python3 -c '
import sys,json
for rs in json.load(sys.stdin)["items"]:
c=rs["spec"]["template"]["spec"]["containers"][0]
if c["image"]=="nginx:1.27" and any(e.get("name")=="RELEASE" for e in c.get("env",[])): sys.exit(0)
sys.exit(1)'"""),
],
},
{
"id": 52, "domain": "WORK", "difficulty": "hard", "title": "Native sidecar with probes and an exact report",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "side"), "pod": name_pick(r, "collector")},
"task": CTX + """
Create pod `%{pod}` in `%{ns}`:
- a **native sidecar** (an init container with `restartPolicy: Always`) named `agent` (busybox:1.36)
that appends `$(date)` to `/var/log/agent/agent.log` every second,
- main container `app` (nginx:1.27) with a readiness probe (HTTP GET / on 80, period 5s)
and a liveness probe (TCP 80),
- shared `emptyDir` `logs` mounted at `/var/log/agent` in both (not at `/var/log`: nginx keeps its
own logs under `/var/log/nginx`, and an emptyDir mounted over `/var/log` hides that directory),
- `terminationGracePeriodSeconds: 5`.
When it is Running, write the names of **all** containers of the pod (init and regular), sorted,
one per line, to `{WS}/q52/containers.txt`, and the pod's `.status.podIP` to `{WS}/q52/ip.txt`.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q52"; mkdir -p "$WS/q52"; $K -n %{ns} delete pod %{pod} --ignore-not-found --force --grace-period=0 --wait=true >/dev/null 2>&1""",
"checks": [
C("native sidecar: initContainer agent with restartPolicy Always", 2, r"""eq "$(j -n %{ns} pod %{pod} '{.spec.initContainers[?(@.name=="agent")].restartPolicy}')" Always"""),
C("probes on app as specified", 2, r"""c='{.spec.containers[?(@.name=="app")]'; eq "$(j -n %{ns} pod %{pod} "$c.readinessProbe.httpGet.path}")" / && eq "$(j -n %{ns} pod %{pod} "$c.readinessProbe.periodSeconds}")" 5 && eq "$(j -n %{ns} pod %{pod} "$c.livenessProbe.tcpSocket.port}")" 80"""),
C("Running, shared log written by agent, grace period 5", 2, r"""eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && eq "$(j -n %{ns} pod %{pod} '{.spec.terminationGracePeriodSeconds}')" 5 && [ -n "$($K -n %{ns} exec %{pod} -c app -- sh -c 'tail -1 /var/log/agent/agent.log' 2>/dev/null)" ]"""),
C("report files exact", 2, r"""want=$( (j -n %{ns} pod %{pod} '{.spec.initContainers[*].name}'; echo; j -n %{ns} pod %{pod} '{.spec.containers[*].name}') | tr ' ' '\n' | grep . | sort); [ -n "$want" ] && [ "$(grep . "$WS/q52/containers.txt" 2>/dev/null | sort)" = "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q52/ip.txt" 2>/dev/null)" "$(j -n %{ns} pod %{pod} '{.status.podIP}')" """),
],
},
{
# Scoped to its own namespace: the cluster-wide version compared a file written mid-exam with the
# whole cluster at submit time, so every pod another task (or a grader probe) created or deleted
# in between failed it. killer.sh-style exact-output tasks work on data the task owns.
"id": 53, "domain": "WORK", "difficulty": "hard", "title": "Namespace audit to exact files",
"params": lambda r: {"ctx": "k8s-c1-H", "ns": ns_pick(r, "audit")},
"task": CTX + """
Namespace `%{ns}` runs a mix of workloads. Produce three files with contents **exactly** as
specified — no headers, one entry per line, sorted with `sort`:
1. `{WS}/q53/no-readiness.txt` — the name of every Pod in `%{ns}` whose **first** container has
no readiness probe (whatever the Pod's phase).
2. `{WS}/q53/per-node.txt` — `<node> <count>`: how many Pods in `%{ns}` are **Running** on each
node, listing only nodes that have at least one.
3. `{WS}/q53/images.txt` — every distinct container image used by Pods in `kube-system`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q53"; mkdir -p "$WS/q53"
$K -n %{ns} delete pod --all --force --grace-period=0 --wait=true >/dev/null 2>&1
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata: {name: web-a, namespace: %{ns}}
spec:
nodeSelector: {kubernetes.io/hostname: $W1}
containers: [{name: web, image: nginx:1.27, readinessProbe: {httpGet: {path: /, port: 80}}}]
---
apiVersion: v1
kind: Pod
metadata: {name: web-b, namespace: %{ns}}
spec:
nodeSelector: {kubernetes.io/hostname: $W2}
containers: [{name: web, image: nginx:1.27, readinessProbe: {tcpSocket: {port: 80}}}]
---
apiVersion: v1
kind: Pod
metadata: {name: batch-a, namespace: %{ns}}
spec:
nodeSelector: {kubernetes.io/hostname: $W1}
containers: [{name: batch, image: busybox:1.36, command: [sleep, "86400"]}]
---
apiVersion: v1
kind: Pod
metadata: {name: cache-a, namespace: %{ns}}
spec:
nodeSelector: {kubernetes.io/hostname: $W3}
containers:
- {name: cache, image: redis:7-alpine}
- {name: exporter, image: nginx:1.27, readinessProbe: {httpGet: {path: /, port: 80}}}
---
apiVersion: v1
kind: Pod
metadata: {name: once-a, namespace: %{ns}}
spec:
restartPolicy: Never
nodeSelector: {kubernetes.io/hostname: $W3}
containers: [{name: job, image: busybox:1.36, command: ["true"]}]
---
apiVersion: v1
kind: Pod
metadata: {name: idle-a, namespace: %{ns}}
spec:
nodeSelector: {kubernetes.io/hostname: $W2}
containers: [{name: idle, image: busybox:1.36, command: [sleep, "86400"]}]
EOF
for i in $(seq 1 60); do [ "$($K -n %{ns} get pod once-a -o jsonpath='{.status.phase}' 2>/dev/null)" = Succeeded ] && break; sleep 2; done
""",
"checks": [
C("no-readiness.txt exact (first container only, every phase)", 3, r"""want=$($K -n %{ns} get pods -o json 2>/dev/null | python3 -c '
import sys,json
for p in json.load(sys.stdin)["items"]:
if not p["spec"]["containers"][0].get("readinessProbe"): print(p["metadata"]["name"])' | sort); [ -n "$want" ] && [ "$(grep . "$WS/q53/no-readiness.txt" 2>/dev/null)" = "$want" ]"""),
C("per-node.txt exact (Running pods only)", 2, r"""want=$($K -n %{ns} get pods -o json 2>/dev/null | python3 -c '
import sys,json,collections
c=collections.Counter(p["spec"].get("nodeName") for p in json.load(sys.stdin)["items"] if p["status"]["phase"]=="Running" and p["spec"].get("nodeName"))
for n in c: print(n, c[n])' | sort); [ -n "$want" ] && [ "$(grep . "$WS/q53/per-node.txt" 2>/dev/null)" = "$want" ]"""),
C("images.txt exact", 2, r"""want=$(j -n kube-system pods '{.items[*].spec.containers[*].image}' | tr ' ' '\n' | sort -u); [ -n "$want" ] && [ "$(grep . "$WS/q53/images.txt" 2>/dev/null)" = "$want" ]"""),
],
},
{
"id": 64, "domain": "WORK", "difficulty": "hard", "title": "The context's namespace",
# ctx_ns: this task reads the context's own default namespace instead of being told one, so
# k8s-c2-AC must be created pointing at *this* task's ns (see context_namespace in ./exam).
"ctx_ns": True,
"params": lambda r: {"ctx": "k8s-c2-AC", "ns": ns_pick(r, "ac"), "dep": name_pick(r, "ledger")},
"task": CTX + """
In the **current namespace of that context** (do not pass `-n`), create Deployment `%{dep}`
(busybox:1.36, `sleep 3600`, 2 replicas, label `owner=ac`) and a ClusterIP Service `%{dep}`
on port 9000 targeting port 9000. Then write the namespace name you deployed into, as reported
by `kubectl config view --minify`, to `{WS}/q64/ns.txt`.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q64"; mkdir -p "$WS/q64"; $K -n %{ns} delete deploy,svc %{dep} --ignore-not-found >/dev/null; $K -n default delete deploy,svc %{dep} --ignore-not-found >/dev/null""",
"checks": [
C("Deployment %{dep} in %{ns} (the context's namespace), 2 available, owner=ac", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 2 && { eq "$(j -n %{ns} deploy %{dep} '{.metadata.labels.owner}')" ac || eq "$(j -n %{ns} deploy %{dep} '{.spec.template.metadata.labels.owner}')" ac; }"""),
C("Service %{dep} port 9000 in the same namespace", 1, r"""eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].port}')" 9000"""),
C("nothing was created in namespace default by mistake", 1, r"""! $K -n default get deploy %{dep} >/dev/null 2>&1"""),
C("ns.txt has the namespace", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q64/ns.txt" 2>/dev/null)" %{ns}"""),
],
},
# ================================================================= NET ====
{
"id": 54, "domain": "NET", "difficulty": "hard", "title": "Zero-trust namespace",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "zt"), "from": ns_pick(r, "front")},
"task": CTX + """
Namespace `%{ns}` runs `api` (label `app=api`, port 8080) and `db` (label `app=db`, port 5432).
Implement zero trust with NetworkPolicies:
1. `default-deny`: deny all ingress **and** egress for every pod in `%{ns}`.
2. `allow-dns`: every pod may egress to DNS (UDP/TCP 53) in `kube-system` only.
3. `api-ingress`: `api` accepts ingress on 8080 only from pods in namespaces labelled `team=%{from}`.
4. `api-to-db`: `api` may egress to `db` on 5432; `db` accepts ingress on 5432 from `api` only.
Remove anything that contradicts this. Label namespace `%{from}` appropriately.
""",
"setup": NS + r"""
mkns %{ns}; mkns %{from}
$K -n %{ns} create deploy api --image=nginx:1.25 --port=8080 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} create deploy db --image=nginx:1.25 --port=5432 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} delete netpol --all >/dev/null 2>&1
# trap: an allow-all left behind
$K apply -f - >/dev/null <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: temp-allow-all, namespace: %{ns}}
spec: {podSelector: {}, policyTypes: [Ingress, Egress], ingress: [{}], egress: [{}]}
EOF
$K label ns %{from} team- >/dev/null 2>&1 || true
""",
"checks": [
C("default-deny covers Ingress and Egress for all pods; allow-all removed", 2, r"""n="$K -n %{ns} get netpol default-deny"; eq "$($n -o jsonpath='{.spec.podSelector}' 2>/dev/null)" '{}' && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress && $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress && [ -z "$($n -o jsonpath='{.spec.ingress}')" ] && [ -z "$($n -o jsonpath='{.spec.egress}')" ] && ! $K -n %{ns} get netpol temp-allow-all >/dev/null 2>&1"""),
C("allow-dns: egress 53 UDP+TCP to kube-system only", 2, r"""$K -n %{ns} get netpol allow-dns -o json 2>/dev/null | python3 -c '
import sys,json; s=json.load(sys.stdin)["spec"]; e=s.get("egress",[])
ok=s.get("podSelector")=={} and any({p.get("protocol") for p in r.get("ports",[]) if str(p.get("port"))=="53"}>={"UDP","TCP"} and all("kube-system" in json.dumps(t.get("namespaceSelector",{})) for t in r.get("to",[])) and r.get("to") for r in e)
sys.exit(0 if ok else 1)'"""),
C("api-ingress: from namespaces team=%{from} on 8080 only; namespace labelled", 2, r"""eq "$(j ns %{from} '{.metadata.labels.team}')" %{from} && $K -n %{ns} get netpol api-ingress -o json 2>/dev/null | python3 -c '
import sys,json; s=json.load(sys.stdin)["spec"]
ok=s["podSelector"].get("matchLabels",{}).get("app")=="api" and len(s.get("ingress",[]))==1 and all(str(p.get("port"))=="8080" for p in s["ingress"][0].get("ports",[])) and s["ingress"][0].get("ports") and all(t.get("namespaceSelector",{}).get("matchLabels",{}).get("team")=="%{from}" for t in s["ingress"][0].get("from",[])) and s["ingress"][0].get("from")
sys.exit(0 if ok else 1)'"""),
C("api-to-db: api egress to db:5432 and db ingress from api:5432", 2, r"""$K -n %{ns} get netpol -o json 2>/dev/null | python3 -c '
import sys,json; items=json.load(sys.stdin)["items"]; eg=ing=False
for n in items:
s=n["spec"]; app=s["podSelector"].get("matchLabels",{}).get("app")
for r in s.get("egress",[]):
if app=="api" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="db" for t in r.get("to",[])) and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): eg=True
for r in s.get("ingress",[]):
if app=="db" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="api" for t in r.get("from",[])) and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): ing=True
sys.exit(0 if eg and ing else 1)'"""),
],
},
{
"id": 55, "domain": "NET", "difficulty": "hard", "title": "TLS Ingress with a generated certificate",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "tls"), "host": r.choice(["secure.lab.local", "portal.lab.local", "admin.lab.local"])},
"task": CTX + """
Namespace `%{ns}` has Deployment+Service `web` (port 80).
1. Generate a self-signed certificate (RSA 2048, 365 days) with CN `%{host}` and store it as a
Secret `%{host}-tls` of type `kubernetes.io/tls`.
2. Create Ingress `web` (class `nginx`) serving host `%{host}` with that TLS secret, path `/` Prefix → `web:80`,
and the annotation `nginx.ingress.kubernetes.io/ssl-redirect: "true"`.
3. Write the certificate's SHA-256 fingerprint (`openssl x509 -fingerprint -sha256`, the value only) to `{WS}/q55/fp.txt`.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q55"; mkdir -p "$WS/q55"; $K -n %{ns} create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} delete secret %{host}-tls --ignore-not-found >/dev/null; $K -n %{ns} delete ingress web --ignore-not-found >/dev/null""",
"checks": [
C("Secret %{host}-tls is kubernetes.io/tls with CN %{host}", 2, r"""eq "$(j -n %{ns} secret %{host}-tls '{.type}')" kubernetes.io/tls && j -n %{ns} secret %{host}-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'CN *= *%{host}'"""),
C("Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", 3, r"""i="$K -n %{ns} get ingress web"; eq "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" nginx && eq "$($i -o jsonpath='{.spec.tls[0].secretName}')" %{host}-tls && eq "$($i -o jsonpath='{.spec.tls[0].hosts[0]}')" %{host} && eq "$($i -o jsonpath='{.spec.rules[0].host}')" %{host} && eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" web && eq "$($i -o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/ssl-redirect}')" true"""),
C("fp.txt matches the secret's certificate fingerprint", 2, r"""fp=$(j -n %{ns} secret %{host}-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//'); [ -n "$fp" ] && grep -qiF "$fp" "$WS/q55/fp.txt" 2>/dev/null"""),
],
},
{
"id": 56, "domain": "NET", "difficulty": "hard", "title": "Gateway API canary with header match",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "canary"), "host": r.choice(["shop", "pay", "cart"]), "w": r.choice([10, 20, 25])},
"task": CTX + """
Gateway API CRDs are installed. Namespace `%{ns}` has Services `v1` and `v2` (port 80).
Create in `%{ns}`:
- Gateway `%{host}-gw` (class `%{host}-gc`, create the GatewayClass, controllerName `example.com/gw`)
with two HTTP listeners: `web` for `%{host}.example.com` and `api` for `api.%{host}.example.com`, both port 80.
- HTTPRoute `%{host}-route` attached to listener `web` only (`sectionName`), with:
- a rule matching header `X-Canary: always` → `v2` (100%),
- a default rule splitting `v1` %{w2}% / `v2` %{w}% by weight.
""",
"params_post": None,
"setup": NS + r"""
mkns %{ns}
$K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 || { curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml -o /tmp/gwapi.yaml && $K apply -f /tmp/gwapi.yaml >/dev/null 2>&1; } || echo "warn: could not install Gateway API CRDs"
for v in v1 v2; do $K -n %{ns} create deploy $v --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} expose deploy $v --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; done
$K delete gatewayclass %{host}-gc --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete gateway %{host}-gw --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete httproute %{host}-route --ignore-not-found >/dev/null 2>&1
""",
"checks": [
C("Gateway with listeners web and api on the right hostnames", 2, r"""g="$K -n %{ns} get gateway %{host}-gw"; eq "$($g -o jsonpath='{.spec.gatewayClassName}' 2>/dev/null)" %{host}-gc && eq "$($g -o jsonpath='{.spec.listeners[?(@.name=="web")].hostname}')" %{host}.example.com && eq "$($g -o jsonpath='{.spec.listeners[?(@.name=="api")].hostname}')" api.%{host}.example.com"""),
C("route attaches to listener web only (sectionName)", 1, r"""eq "$(j -n %{ns} httproute %{host}-route '{.spec.parentRefs[0].sectionName}')" web && [ -z "$(j -n %{ns} httproute %{host}-route '{.spec.parentRefs[1]}')" ]"""),
C("header rule X-Canary: always -> v2", 2, r"""$K -n %{ns} get httproute %{host}-route -o json 2>/dev/null | python3 -c '
import sys,json; r=json.load(sys.stdin)["spec"]["rules"]
ok=any(any(any(h.get("name","").lower()=="x-canary" and h.get("value")=="always" for h in m.get("headers",[])) for m in x.get("matches",[])) and x["backendRefs"][0]["name"]=="v2" and len(x["backendRefs"])==1 for x in r)
sys.exit(0 if ok else 1)'"""),
C("default rule weights v1 %{w2} / v2 %{w}", 2, r"""$K -n %{ns} get httproute %{host}-route -o json 2>/dev/null | python3 -c '
import sys,json; r=json.load(sys.stdin)["spec"]["rules"]
ok=any((not x.get("matches") or all(not m.get("headers") for m in x["matches"])) and {b["name"]:b.get("weight") for b in x["backendRefs"]}=={"v1":%{w2},"v2":%{w}} for x in r if len(x.get("backendRefs",[]))==2)
sys.exit(0 if ok else 1)'"""),
],
},
{
"id": 57, "domain": "NET", "breaks": "coredns", "difficulty": "hard", "title": "DNS down: three faults", "disruptive": True,
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
Nothing in the cluster resolves DNS. Restore it. There is more than one cause.
When done, `nslookup kubernetes.default.svc.cluster.local` and `nslookup kubernetes.io` must
succeed from a pod in `default` (use the FQDN: busybox's nslookup does not apply the pod's
`search` domains), CoreDNS must have 2 available replicas, and no NetworkPolicy may remain in
`kube-system`.
""",
"setup": r"""
$K -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' > /tmp/Corefile.q57
sed -i 's|forward \. /etc/resolv.conf|forward . 10.255.255.254|' /tmp/Corefile.q57
$K -n kube-system create cm coredns --from-file=Corefile=/tmp/Corefile.q57 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n kube-system scale deploy coredns --replicas=0 >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: maintenance-lockdown, namespace: kube-system}
spec: {podSelector: {matchLabels: {k8s-app: kube-dns}}, policyTypes: [Ingress], ingress: []}
EOF
""",
"checks": [
C("Corefile forwards to /etc/resolv.conf", 2, r"""j -n kube-system cm coredns '{.data.Corefile}' | grep -q 'forward \. /etc/resolv.conf'"""),
C("CoreDNS has 2 available replicas", 2, r"""for i in $(seq 1 12); do [ "$(j -n kube-system deploy coredns '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 5; done; exit 1"""),
C("no NetworkPolicy in kube-system", 1, r"""[ "$($K -n kube-system get netpol --no-headers 2>/dev/null | wc -l)" = 0 ]"""),
C("resolution works from a pod", 3, r"""
$K -n default delete pod dns-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default run dns-probe --image=busybox:1.36 --restart=Never -- sh -c 'for i in 1 2 3 4 5 6; do nslookup kubernetes.default.svc.cluster.local >/dev/null 2>&1 && nslookup kubernetes.io >/dev/null 2>&1 && exit 0; sleep 5; done; exit 1' >/dev/null 2>&1
for i in $(seq 1 20); do ph=$(j -n default pod dns-probe '{.status.phase}'); [ "$ph" = Succeeded ] && { $K -n default delete pod dns-probe --wait=false >/dev/null; exit 0; }; [ "$ph" = Failed ] && break; sleep 3; done
$K -n default delete pod dns-probe --wait=false >/dev/null; exit 1"""),
],
},
# ================================================================ STOR ====
{
"id": 58, "domain": "STOR", "difficulty": "hard", "title": "Bind the right local volume",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "lv"), "pvc": name_pick(r, "fast-data")},
"task": CTX + """
Two PersistentVolumes of class `local-fast` exist. Only one of them lives on the node labelled
`disk=nvme`. Create PVC `%{pvc}` in `%{ns}` (RWO, 1Gi, class `local-fast`) that binds to **that**
PV — use a label selector, do not edit the PVs — and pod `consumer` (busybox:1.36, `sleep 3600`)
mounting it at `/data`. The pod must run on the nvme node. Do not delete either PV.
""",
"setup": NS + r"""
mkns %{ns}
for n in "$W1" "$W2" "$W3"; do $K label node $n disk- >/dev/null 2>&1 || true; done
$K label node "$W2" disk=nvme --overwrite >/dev/null
$K -n %{ns} delete pod consumer --ignore-not-found --wait=false >/dev/null; $K -n %{ns} delete pvc %{pvc} --ignore-not-found >/dev/null
# The PV names are fixed but every session draws a new namespace, so a claim from an earlier run
# (another namespace, often with its pod still running) keeps lv-hdd/lv-nvme Bound. pv-protection
# then holds `kubectl delete pv` forever and the seed timed out. Release them from whoever has them.
for pv in lv-hdd lv-nvme; do
cns=$(j pv $pv '{.spec.claimRef.namespace}'); cn=$(j pv $pv '{.spec.claimRef.name}')
[ -n "$cns" ] && [ -n "$cn" ] || continue
for p in $($K -n "$cns" get pod -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.volumes[*].persistentVolumeClaim.claimName}{"\n"}{end}' 2>/dev/null | awk -v c="$cn" '{for(i=2;i<=NF;i++) if($i==c) print $1}'); do
$K -n "$cns" delete pod "$p" --grace-period=0 --force >/dev/null 2>&1
done
$K -n "$cns" delete pvc "$cn" --ignore-not-found --wait=false >/dev/null 2>&1
done
$K delete pv lv-hdd lv-nvme --ignore-not-found --timeout=90s >/dev/null 2>&1 || true
for pair in "lv-hdd:$W1:hdd:2Gi" "lv-nvme:$W2:nvme:1Gi"; do IFS=: read name node kind size <<<"$pair"
nx "$node" "mkdir -p /mnt/$name"
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolume
metadata: {name: $name, labels: {media: $kind}}
spec:
capacity: {storage: $size}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: local-fast
local: {path: /mnt/$name}
nodeAffinity: {required: {nodeSelectorTerms: [{matchExpressions: [{key: kubernetes.io/hostname, operator: In, values: [$node]}]}]}}
EOF
done
""",
"checks": [
C("PVC uses a selector and is Bound to lv-nvme", 3, r"""[ -n "$(j -n %{ns} pvc %{pvc} '{.spec.selector}')" ] && eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" lv-nvme && eq "$(j -n %{ns} pvc %{pvc} '{.status.phase}')" Bound"""),
C("consumer Running on the nvme node with /data mounted", 2, r"""eq "$(j -n %{ns} pod consumer '{.status.phase}')" Running && eq "$(j -n %{ns} pod consumer '{.spec.nodeName}')" "$W2" && j -n %{ns} pod consumer '{.spec.containers[0].volumeMounts[*].mountPath}' | grep -qw /data"""),
C("both PVs still exist, unmodified affinity", 1, r"""eq "$(j pv lv-hdd '{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}')" "$W1" && eq "$(j pv lv-nvme '{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}')" "$W2" """),
],
},
{
"id": 59, "domain": "STOR", "difficulty": "hard", "title": "Migrate data between claims",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "mig"), "n": r.choice([7, 11, 13])},
"task": CTX + """
In `%{ns}`, PVC `old-data` holds files a running pod `legacy` wrote (`/data`). Storage is being
replaced:
1. Create PVC `new-data` (default StorageClass, 1Gi, RWO).
2. Copy **everything** from `old-data` to `new-data` using a Job named `migrate` (busybox:1.36).
The Job must complete; use whatever mounts you need.
3. Re-point pod `legacy` to `new-data` (same name, same namespace, mount at `/data`) and get it Running.
4. Delete PVC `old-data` afterwards. Its PV must be released, not deleted (check its reclaim policy first).
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod legacy --ignore-not-found --wait=true >/dev/null; $K -n %{ns} delete job migrate --ignore-not-found >/dev/null; $K -n %{ns} delete pvc old-data new-data --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: old-data, namespace: %{ns}}
spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 1Gi}}}
---
apiVersion: v1
kind: Pod
metadata: {name: legacy, namespace: %{ns}}
spec:
containers: [{name: app, image: busybox:1.36, command: [sh, -c, "for i in \$(seq 1 %{n}); do echo record-\$i > /data/file-\$i.txt; done; sleep 3600"], volumeMounts: [{name: d, mountPath: /data}]}]
volumes: [{name: d, persistentVolumeClaim: {claimName: old-data}}]
EOF
for i in $(seq 1 30); do [ "$(j -n %{ns} pod legacy '{.status.phase}')" = Running ] && break; sleep 2; done
pv=$(j -n %{ns} pvc old-data '{.spec.volumeName}'); [ -n "$pv" ] && $K patch pv $pv -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' >/dev/null
echo "$pv" > "$WS/.q59-pv"
""",
"checks": [
C("Job migrate completed", 2, r"""eq "$(j -n %{ns} job migrate '{.status.succeeded}')" 1"""),
C("legacy Running on new-data with all %{n} files intact", 3, r"""eq "$(j -n %{ns} pod legacy '{.status.phase}')" Running && eq "$(j -n %{ns} pod legacy '{.spec.volumes[?(@.name!="kube-api-access")].persistentVolumeClaim.claimName}' | tr ' ' '\n' | grep -v '^$' | head -1)" new-data && eq "$($K -n %{ns} exec legacy -- sh -c 'ls /data/file-*.txt | wc -l' 2>/dev/null | tr -d ' ')" %{n} && eq "$($K -n %{ns} exec legacy -- cat /data/file-%{n}.txt 2>/dev/null)" record-%{n}"""),
C("old-data deleted; its PV Released (not deleted)", 2, r"""! $K -n %{ns} get pvc old-data >/dev/null 2>&1 && pv=$(cat "$WS/.q59-pv" 2>/dev/null) && eq "$(j pv $pv '{.status.phase}')" Released"""),
],
},
# ============================================================== TSHOOT ====
{
"id": 60, "domain": "TSHOOT", "breaks": "w2-kubelet", "difficulty": "hard", "title": "Worker node: three things wrong", "disruptive": True,
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
Node `{W2}` is unusable. Make it fully usable again: `Ready`, schedulable, kubelet running and
enabled, and a new pod pinned to it must reach Running. Nothing else on the cluster may be changed.
""",
"setup": r"""
$K cordon "$W2" >/dev/null 2>&1 || true
nx "$W2" '
sed -i "s|clientCAFile: /etc/kubernetes/pki/ca.crt|clientCAFile: /etc/kubernetes/pki/ca-wrong.crt|" /var/lib/kubelet/config.yaml
systemctl disable kubelet >/dev/null 2>&1; systemctl stop kubelet'
""",
"checks": [
C("node Ready", 3, r"""for i in $(seq 1 12); do [ "$(j node "$W2" '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && exit 0; sleep 5; done; exit 1"""),
C("node uncordoned (collateral check)", 2, r"""[ "$(j node "$W2" '{.spec.unschedulable}')" != true ]"""),
C("kubelet active and enabled, config points at a real CA", 2, r"""eq "$(nx "$W2" 'systemctl is-active kubelet')" active && eq "$(nx "$W2" 'systemctl is-enabled kubelet')" enabled && f=$(nx "$W2" 'grep clientCAFile /var/lib/kubelet/config.yaml' | awk '{print $2}') && nx "$W2" "test -f $f" """),
C("a pod pinned to the node runs", 2, r"""
$K -n default delete pod node-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default run node-probe --image=busybox:1.36 --restart=Never --overrides="{\"spec\":{\"nodeName\":\"$W2\"}}" -- sleep 120 >/dev/null 2>&1
ok=1; for i in $(seq 1 20); do [ "$(j -n default pod node-probe '{.status.phase}')" = Running ] && { ok=0; break; }; sleep 3; done
$K -n default delete pod node-probe --wait=false >/dev/null 2>&1; exit $ok"""),
],
},
{
"id": 61, "domain": "TSHOOT", "breaks": ["scheduler", "controller-manager"], "difficulty": "hard", "title": "Two control-plane components down", "disruptive": True,
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
New Deployments create no pods, and pods that do get created stay `Pending`. Two different
control-plane components are failing for two different reasons. Repair both on the control-plane node.
Afterwards a new Deployment must reach Available within a minute.
""",
"setup": r"""
nx "$CP" '
f=/etc/kubernetes/manifests/kube-scheduler.yaml; grep -q "^ *- kube-scheduler$" $f && sed -i "s|^\( *\)- kube-scheduler$|\1- kube-schedulerr|" $f
g=/etc/kubernetes/manifests/kube-controller-manager.yaml; grep -q "image: registry.k8s.io/kube-controller-manager:v" $g && sed -i "s|\(image: registry.k8s.io/kube-controller-manager:v[0-9.]*\)|\1-broken|" $g
'
""",
"checks": [
C("kube-scheduler manifest repaired and Running", 3, r"""nx "$CP" 'grep -q "^ *- kube-scheduler$" /etc/kubernetes/manifests/kube-scheduler.yaml' && eq "$(j -n kube-system pod -l component=kube-scheduler '{.items[0].status.phase}')" Running"""),
C("kube-controller-manager image repaired and Running", 3, r"""! nx "$CP" 'grep -q "\-broken" /etc/kubernetes/manifests/kube-controller-manager.yaml' && eq "$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].status.phase}')" Running"""),
C("a new Deployment becomes Available", 3, r"""
$K -n default delete deploy cp-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default create deploy cp-probe --image=busybox:1.36 -- sleep 300 >/dev/null 2>&1
ok=1; for i in $(seq 1 20); do [ "$(j -n default deploy cp-probe '{.status.availableReplicas}')" = 1 ] && { ok=0; break; }; sleep 3; done
$K -n default delete deploy cp-probe --wait=false >/dev/null 2>&1; exit $ok"""),
],
},
{
"id": 62, "domain": "TSHOOT", "difficulty": "hard", "title": "Application down for four reasons",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "checkout"), "dep": name_pick(r, "checkout")},
"task": CTX + """
Deployment `%{dep}` in `%{ns}` should have 3 Available replicas and Service `%{dep}` should route
to them on port 80. Right now nothing works. Fix it **in place** — the Deployment must keep its
UID (do not delete and recreate it) and the Service must keep its ClusterIP.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy,svc,quota,secret --all >/dev/null 2>&1
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: ResourceQuota
metadata: {name: tight, namespace: %{ns}}
spec: {hard: {pods: "1"}}
---
apiVersion: v1
kind: Secret
metadata: {name: %{dep}-env, namespace: %{ns}}
stringData: {API_KEY: abc123}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 3
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: web
image: nginx:1.27-nope
ports: [{containerPort: 80}]
env: [{name: DB_URL, valueFrom: {secretKeyRef: {name: %{dep}-env, key: DATABASE_URL}}}]
resources: {requests: {cpu: 10m, memory: 16Mi}}
---
apiVersion: v1
kind: Service
metadata: {name: %{dep}, namespace: %{ns}}
spec:
selector: {app: %{dep}-web}
ports: [{port: 80, targetPort: 8080}]
EOF
j -n %{ns} deploy %{dep} '{.metadata.uid}' > "$WS/.q62-uid"; j -n %{ns} svc %{dep} '{.spec.clusterIP}' > "$WS/.q62-ip"
""",
"checks": [
C("3 replicas Available", 3, r"""for i in $(seq 1 6); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 3 ] && exit 0; sleep 5; done; exit 1"""),
C("Service routes: endpoints ready, targetPort 80", 2, r"""eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].targetPort}')" 80 && [ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{dep} '{.items[*].endpoints[?(@.conditions.ready==true)].addresses[*]}')" ]"""),
C("Deployment UID and Service ClusterIP unchanged (fixed in place)", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 || exit 1 # only once it is actually fixed
eq "$(j -n %{ns} deploy %{dep} '{.metadata.uid}')" "$(cat "$WS/.q62-uid")" && eq "$(j -n %{ns} svc %{dep} '{.spec.clusterIP}')" "$(cat "$WS/.q62-ip")" """),
C("quota still present and permits the 3 pods", 1, r"""p=$(j -n %{ns} quota tight '{.spec.hard.pods}'); [ -n "$p" ] && [ "$p" -ge 3 ]"""),
],
},
{
"id": 63, "domain": "TSHOOT", "difficulty": "hard", "title": "Cluster facts, exactly",
"params": lambda r: {"ctx": "k8s-c1-H", "node": r.choice(["{W1}", "{W2}", "{W3}"])},
"task": CTX + """
Write `{WS}/q63/facts.txt` with these lines, values taken from the running cluster:
```
pod-cidr=<cluster pod CIDR as configured on kube-controller-manager>
service-cidr=<service CIDR as configured on kube-apiserver>
dns-ip=<ClusterIP of the kube-dns Service>
proxy-mode=<kube-proxy mode from its ConfigMap (empty means iptables — write iptables)>
%{node}-pod-cidr=<.spec.podCIDR of that node>
```
""",
"setup": r"""rm -rf "$WS/q63"; mkdir -p "$WS/q63" """,
"checks": [
C("pod-cidr and service-cidr", 2, r"""f="$WS/q63/facts.txt"; pc=$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'cluster-cidr=[^"]*' | cut -d= -f2); sc=$(j -n kube-system pod -l component=kube-apiserver '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'service-cluster-ip-range=[^"]*' | cut -d= -f2); grep -qx "pod-cidr=$pc" "$f" 2>/dev/null && grep -qx "service-cidr=$sc" "$f" """),
C("dns-ip and proxy-mode", 2, r"""f="$WS/q63/facts.txt"; d=$(j -n kube-system svc kube-dns '{.spec.clusterIP}'); m=$(j -n kube-system cm kube-proxy '{.data.config\.conf}' | grep '^mode:' | awk '{print $2}' | tr -d '"'); [ -n "$m" ] || m=iptables; grep -qx "dns-ip=$d" "$f" 2>/dev/null && grep -qx "proxy-mode=$m" "$f" """),
C("%{node} pod CIDR", 1, r"""grep -qx "%{node}-pod-cidr=$(j node %{node} '{.spec.podCIDR}')" "$WS/q63/facts.txt" 2>/dev/null"""),
],
},
{
"id": 66, "domain": "TSHOOT", "breaks": "apiserver", "difficulty": "hard", "title": "Nothing works: kube-apiserver itself is down", "disruptive": True,
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
Every `kubectl` command from every context times out or refuses to connect. This is not a
network problem — a control-plane component itself is failing to start. `kubectl` is unusable
until you fix it, so investigate directly on the control-plane node's containers.
Repair the failing component on the control-plane node (`{SHELL_CP}`). Afterwards, `kubectl get
nodes` must work again, and every other control-plane component must still be healthy.
""",
"setup": r"""
nx "$CP" '
f=/etc/kubernetes/manifests/kube-apiserver.yaml
grep -q "^ *- kube-apiserver$" $f && sed -i "s|^\( *\)- kube-apiserver$|\1- kube-apiserverr|" $f
'
""",
"checks": [
C("kube-apiserver manifest repaired and Running", 4, r"""nx "$CP" 'grep -q "^ *- kube-apiserver$" /etc/kubernetes/manifests/kube-apiserver.yaml' && eq "$(j -n kube-system pod -l component=kube-apiserver '{.items[0].status.phase}')" Running"""),
C("cluster fully reachable again (get nodes, create a pod)", 4, r"""
$K get nodes >/dev/null 2>&1 || exit 1
$K -n default delete pod api-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default run api-probe --image=busybox:1.36 --restart=Never -- sleep 60 >/dev/null 2>&1
ok=1; for i in $(seq 1 20); do [ "$(j -n default pod api-probe '{.status.phase}')" = Running ] && { ok=0; break; }; sleep 3; done
$K -n default delete pod api-probe --wait=false >/dev/null 2>&1; exit $ok"""),
C("etcd, scheduler and controller-manager unaffected (no collateral damage)", 2, r"""for c in etcd kube-scheduler kube-controller-manager; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || exit 1; done"""),
],
},
]
# derived params: w2 = 100 - w for the canary split
for _q in QUESTIONS:
if _q["id"] == 56:
_orig = _q["params"]
_q["params"] = lambda r, _o=_orig: (lambda p: {**p, "w2": 100 - p["w"]})(_o(r))