kubestronaut-sim/ckad/questions.py
vumas3r f3d0e536b6 Exam simulator for CKA, CKAD and CKS with per-task hosts and a remote desktop
kubestronaut-sim runs each exam task on a host of its own - a kind (kubeadm)
cluster in rootless podman, or a QEMU/KVM Ubuntu VM for the CKS tasks that
need their own kernel (Falco, AppArmor) - and puts a browser desktop in front
of them, the way killer.sh and sailor.sh do.

- ksim, sim/: the engine (sessions, drills, study mode, grading, coverage,
  audit), host and VM lifecycle, the desktop
- images/: exam host images (common, CKS variant with its tools, VM guest)
- cka/ (131 tasks), ckad/ (105), cks/ (110): task banks, curricula with
  study notes, reference solutions and scripted solutions

Every domain of every track has at least 17 pool tasks. `./ksim audit` proves
a task three ways: it seeds cleanly on its own host, scores nothing before
anyone has worked on it, and reaches full marks when its scripted solution is
run on the host as `candidate`. All 346 tasks pass (CKA audited 2026-10-13,
CKAD and CKS 2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-13 16:29:33 -06:00

629 lines
31 KiB
Python

"""CKAD track: 85 pool tasks - 17 in each of the five domains - and Simulator Set A (38-57,
bank_sim_a.py). Tasks 1-12 are here, 13-37 in bank_extra.py, 58-64 in bank_more.py, and one file
per domain holds the ones that took it to 17: bank_net.py (65-76), bank_design.py (77-84),
bank_deploy.py (85-93), bank_obs.py (94-99), bank_env.py (100-105). Same format as the CKA banks.
curriculum.py maps every task to the objectives it practises (./ksim objectives).
CKAD is a build-things exam: less cluster surgery, more "produce the right object, fast".
The clock is the adversary, so these are mostly single-object tasks graded exactingly.
"""
from sim.bank import C, NS, ns_pick, name_pick
TRACK = {
"id": "ckad",
"name": "Certified Kubernetes Application Developer",
"minutes": 120,
"pass_pct": 66,
# Kubernetes minor of the real exam environment, which picks the kind cluster. LF FAQ /
# "Important Instructions: CKA and CKAD", checked 2026-10-02: "The CKAD environment is currently
# running Kubernetes v1.37" (it went there from v1.35 at the end of September 2026).
"k8s": "v1.37",
# Worker nodes of a task's cluster unless the task says otherwise ("workers", or WORKERS below).
# None: a CKAD task works through the API, and a single-node cluster runs its Pods on the
# (then untainted) control-plane node.
"workers": 0,
# CKAD_Curriculum_v1.37.pdf (cncf/curriculum) - the same document as v1.35, so these and every
# competency the bank covers are unchanged by the version bump
"domains": {
"DESIGN": "Application Design and Build (20%)",
"DEPLOY": "Application Deployment (20%)",
"OBS": "Application Observability and Maintenance (15%)",
"ENV": "Application Environment, Configuration and Security (25%)",
"NET": "Services and Networking (20%)",
},
# 16 tasks in 120 minutes, weighted like the exam (20/20/15/25/20 %). The pool is 17 per
# domain, so no two mocks are the same set.
"quota": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3},
"quota_hard": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3},
# fixed simulator sets (./ksim sim a) - excluded from random sampling
"sets": {"a": "Simulator Set A (killer.sh-style)"},
}
QUESTIONS = [
# ---------------------------------------------------------------- DESIGN ----
{
"id": 1, "domain": "DESIGN", "title": "Adapter sidecar",
"params": lambda r: {"ns": ns_pick(r, "app"), "pod": name_pick(r, "adapter")},
"task": """
Create Pod `%{pod}` in Namespace `%{ns}` with two containers sharing an `emptyDir` named `spool`
mounted at `/spool` in both:
- `writer` (busybox:1.36): every 2 seconds appends a line `metric 1` to `/spool/raw.txt`
- `adapter` (busybox:1.36): runs `tail -f /spool/raw.txt`
`kubectl logs %{pod} -c adapter` must show the writer's lines.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null""",
"checks": [
C("pod Running with containers writer and adapter", 2, r"""
p="$K -n %{ns} get pod %{pod}"
eq "$($p -o jsonpath='{.status.phase}' 2>/dev/null)" Running && $p -o jsonpath='{.spec.containers[*].name}' | grep -qw writer && $p -o jsonpath='{.spec.containers[*].name}' | grep -qw adapter"""),
C("emptyDir 'spool' mounted at /spool in both containers", 2, r"""
p="$K -n %{ns} get pod %{pod}"
[ -n "$($p -o jsonpath='{.spec.volumes[?(@.name=="spool")].emptyDir}')" ] \
&& eq "$($p -o jsonpath='{.spec.containers[?(@.name=="writer")].volumeMounts[?(@.name=="spool")].mountPath}')" /spool \
&& eq "$($p -o jsonpath='{.spec.containers[?(@.name=="adapter")].volumeMounts[?(@.name=="spool")].mountPath}')" /spool"""),
C("adapter logs show the writer's output", 2, r"""sleep 3; $K -n %{ns} logs %{pod} -c adapter --tail=3 2>/dev/null | grep -q 'metric 1'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes: [{name: spool, emptyDir: {}}]
containers:
- name: writer
image: busybox:1.36
command: [sh, -c, "while true; do echo 'metric 1' >> /spool/raw.txt; sleep 2; done"]
volumeMounts: [{name: spool, mountPath: /spool}]
- name: adapter
image: busybox:1.36
command: [sh, -c, "tail -f /spool/raw.txt"]
volumeMounts: [{name: spool, mountPath: /spool}]
EOF
k -n %{ns} logs %{pod} -c adapter
""",
},
{
"id": 2, "domain": "DESIGN", "title": "Init container waits for a dependency",
"params": lambda r: {"ns": ns_pick(r, "boot"), "pod": name_pick(r, "consumer")},
"task": """
Namespace `%{ns}` has a Service `backend` with no endpoints yet.
1. Create Pod `%{pod}` with an init container `wait-for-backend` (busybox:1.36) that blocks until
Service `backend` actually **answers on port 80**, and a main container `app` (nginx:1.27).
It will sit in `Init:0/1`.
2. Then create Deployment `backend` (nginx:1.27, 1 replica, Pod label `app=backend`) so the
Service gets an endpoint and your Pod starts.
Two traps here: a ClusterIP Service gets a DNS record the moment it is created, endpoints or not,
so waiting on name resolution alone never blocks. And busybox `nslookup` exits non-zero even when
it succeeds, because the search-domain attempts after the hit return NXDOMAIN. Wait on a real
connection instead.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null
$K -n %{ns} delete deploy backend --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Service
metadata: {name: backend, namespace: %{ns}}
spec: {selector: {app: backend}, ports: [{port: 80}]}
EOF
""",
"checks": [
C("pod has an init container named wait-for-backend", 2, r"""[ -n "$(j -n %{ns} pod %{pod} '{.spec.initContainers[?(@.name=="wait-for-backend")].name}')" ]"""),
C("backend Deployment available with an endpoint on the Service", 3, r"""
eq "$(j -n %{ns} deploy backend '{.status.availableReplicas}')" 1 \
&& [ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=backend '{.items[*].endpoints[*].addresses[*]}')" ]"""),
C("pod is now Running", 3, r"""for i in $(seq 1 12); do [ "$(j -n %{ns} pod %{pod} '{.status.phase}')" = Running ] && exit 0; sleep 5; done; exit 1"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
initContainers:
- name: wait-for-backend
image: busybox:1.36
command: [sh, -c, "until wget -T 2 -q -O- http://backend:80 >/dev/null 2>&1; do echo waiting; sleep 2; done"]
containers: [{name: app, image: nginx:1.27}]
EOF
k -n %{ns} get pod %{pod} # Init:0/1
k -n %{ns} create deploy backend --image=nginx:1.27
k -n %{ns} patch deploy backend -p '{"spec":{"template":{"metadata":{"labels":{"app":"backend"}}}}}'
# A ClusterIP Service resolves in DNS from the moment it exists - endpoints change nothing about
# that - so `until nslookup backend` would not block even before you add the Deployment. Worse,
# busybox nslookup returns 1 even on a successful lookup, because it walks the whole search list
# and the trailing NXDOMAINs set the exit status, so that loop never terminates either way.
# With no endpoints, kube-proxy REJECTs the connection, so wget fails fast and the loop is real.
""",
},
{
"id": 3, "domain": "DESIGN", "title": "CronJob with deadlines",
"params": lambda r: {"ns": ns_pick(r, "batch"), "cj": name_pick(r, "digest"), "m": r.choice([3, 5, 7])},
"task": """
In `%{ns}` create CronJob `%{cj}`:
- schedule: every %{m} minutes
- image `busybox:1.36`, command prints `digest`
- a run that cannot start within 10 seconds of its schedule is skipped
- a run that takes longer than 20 seconds is killed
- keep 2 successful and 2 failed Jobs
- restartPolicy `Never`
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete cj %{cj} --ignore-not-found >/dev/null""",
"checks": [
C("schedule and history limits", 2, r"""
c="$K -n %{ns} get cj %{cj}"
eq "$($c -o jsonpath='{.spec.schedule}' 2>/dev/null)" "*/%{m} * * * *" && eq "$($c -o jsonpath='{.spec.successfulJobsHistoryLimit}')" 2 && eq "$($c -o jsonpath='{.spec.failedJobsHistoryLimit}')" 2"""),
C("startingDeadlineSeconds 10 and activeDeadlineSeconds 20", 3, r"""
c="$K -n %{ns} get cj %{cj}"
eq "$($c -o jsonpath='{.spec.startingDeadlineSeconds}' 2>/dev/null)" 10 && eq "$($c -o jsonpath='{.spec.jobTemplate.spec.activeDeadlineSeconds}')" 20"""),
C("restartPolicy Never", 1, r"""eq "$(j -n %{ns} cj %{cj} '{.spec.jobTemplate.spec.template.spec.restartPolicy}')" Never"""),
],
"solution": """
k -n %{ns} create cj %{cj} --image=busybox:1.36 --schedule='*/%{m} * * * *' $do -- sh -c "echo digest" > cj.yaml
# spec.startingDeadlineSeconds: 10 (skip a late start)
# spec.successfulJobsHistoryLimit: 2 / failedJobsHistoryLimit: 2
# spec.jobTemplate.spec.activeDeadlineSeconds: 20 (kill an overrunning run)
# spec.jobTemplate.spec.template.spec.restartPolicy: Never
k apply -f cj.yaml
# startingDeadlineSeconds lives on the CronJob, activeDeadlineSeconds on the Job template.
""",
},
# ---------------------------------------------------------------- DEPLOY ----
{
"id": 4, "domain": "DEPLOY", "title": "Roll forward, then back",
"params": lambda r: {"ns": ns_pick(r, "rel"), "dep": name_pick(r, "frontend")},
"task": """
Deployment `%{dep}` in `%{ns}` runs `nginx:1.25` with 3 replicas.
1. Update the image to `nginx:1.27` and record a change-cause annotation of `pick 1.27`.
2. Then update it to `nginx:9.99-nope`, which will never become ready.
3. Roll back so all 3 replicas are Available on `nginx:1.27` again.
""",
"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 --replicas=3 >/dev/null
$K -n %{ns} rollout status deploy %{dep} --timeout=120s >/dev/null 2>&1
""",
"checks": [
C("running image is nginx:1.27 with 3 available", 4, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].image}')" nginx:1.27 && eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3"""),
C("rollout history has at least three revisions", 2, r"""[ "$($K -n %{ns} rollout history deploy %{dep} 2>/dev/null | grep -c '^[0-9]')" -ge 3 ]"""),
C("a change-cause annotation is recorded", 2, r"""j -n %{ns} deploy %{dep} '{.metadata.annotations.kubernetes\.io/change-cause}' | grep -q ."""),
],
"solution": """
k -n %{ns} set image deploy %{dep} nginx=nginx:1.27
k -n %{ns} annotate deploy %{dep} kubernetes.io/change-cause='pick 1.27' --overwrite
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} set image deploy %{dep} nginx=nginx:9.99-nope
k -n %{ns} rollout status deploy %{dep} --timeout=30s # never completes
k -n %{ns} rollout history deploy %{dep}
k -n %{ns} rollout undo deploy %{dep}
k -n %{ns} rollout status deploy %{dep}
""",
},
{
"id": 5, "domain": "DEPLOY", "title": "Blue/green cutover",
"params": lambda r: {"ns": ns_pick(r, "bg"), "svc": name_pick(r, "shop")},
"task": """
Namespace `%{ns}` has Deployment `%{svc}-blue` (Pod labels `app=%{svc}`, `version=blue`) behind
Service `%{svc}`.
1. Create Deployment `%{svc}-green`: 2 replicas, image `nginx:1.27`, Pod labels `app=%{svc}` and
`version=green`.
2. When it is fully Available, switch Service `%{svc}` to serve green only.
3. Leave blue running untouched so the switch can be reversed.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{svc}-blue %{svc}-green --ignore-not-found >/dev/null
$K -n %{ns} delete svc %{svc} --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{svc}-blue, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{svc}, version: blue}}
template:
metadata: {labels: {app: %{svc}, version: blue}}
spec: {containers: [{name: web, image: nginx:1.25, ports: [{containerPort: 80}]}]}
---
apiVersion: v1
kind: Service
metadata: {name: %{svc}, namespace: %{ns}}
spec: {selector: {app: %{svc}, version: blue}, ports: [{port: 80, targetPort: 80}]}
EOF
""",
"checks": [
C("green Deployment available with the right labels", 3, r"""
eq "$(j -n %{ns} deploy %{svc}-green '{.status.availableReplicas}')" 2 \
&& eq "$(j -n %{ns} deploy %{svc}-green '{.spec.template.metadata.labels.version}')" green \
&& eq "$(j -n %{ns} deploy %{svc}-green '{.spec.template.metadata.labels.app}')" %{svc}"""),
C("Service selector now targets version=green", 3, r"""eq "$(j -n %{ns} svc %{svc} '{.spec.selector.version}')" green"""),
C("every Service endpoint belongs to a green Pod", 2, r"""
ips=$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{svc} '{.items[*].endpoints[*].addresses[*]}')
[ -n "$ips" ] || exit 1
for ip in $ips; do j -n %{ns} pod -l version=green '{.items[*].status.podIP}' | grep -qw "$ip" || exit 1; done"""),
C("blue is still running, untouched, next to green", 2, r"""
[ -n "$(j -n %{ns} deploy %{svc}-green '{.metadata.name}')" ] || exit 1
eq "$(j -n %{ns} deploy %{svc}-blue '{.spec.replicas}/{.spec.template.spec.containers[0].image}/{.spec.template.metadata.labels.version}')" 2/nginx:1.25/blue || exit 1
for i in $(seq 1 20); do eq "$(j -n %{ns} deploy %{svc}-blue '{.status.availableReplicas}')" 2 && exit 0; sleep 3; done; exit 1"""),
],
"solution": """
k -n %{ns} get deploy %{svc}-blue -o yaml > green.yaml
# rename to %{svc}-green, change every version: blue -> green, image -> nginx:1.27,
# strip status/uid/resourceVersion/creationTimestamp
k apply -f green.yaml
k -n %{ns} rollout status deploy %{svc}-green
k -n %{ns} patch svc %{svc} -p '{"spec":{"selector":{"app":"%{svc}","version":"green"}}}'
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{svc}
# Rolling back is the same patch with version: blue - which is why blue stays up.
""",
},
{
"id": 6, "domain": "DEPLOY", "title": "Helm release lifecycle",
"params": lambda r: {"ns": ns_pick(r, "helm"), "rel": name_pick(r, "site")},
"task": """
A chart is at `{WS}/q6/chart`.
1. Install it into Namespace `%{ns}` (create it) as release `%{rel}` with `replicaCount=2`.
2. Upgrade the release to `replicaCount=4`.
3. Roll the release back so the Deployment is at 2 replicas again — without uninstalling it.
4. Write the release's current revision number to `{WS}/q6/revision.txt`.
""",
"setup": r"""
rm -rf "$WS/q6"; mkdir -p "$WS/q6/chart/templates"
cat > "$WS/q6/chart/Chart.yaml" <<'EOF'
apiVersion: v2
name: site
version: 0.1.0
EOF
cat > "$WS/q6/chart/values.yaml" <<'EOF'
replicaCount: 1
image: nginx:1.27
EOF
cat > "$WS/q6/chart/templates/deploy.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: {{ .Release.Name }}-site}
spec:
replicas: {{ .Values.replicaCount }}
selector: {matchLabels: {app: {{ .Release.Name }}-site}}
template:
metadata: {labels: {app: {{ .Release.Name }}-site}}
spec: {containers: [{name: web, image: "{{ .Values.image }}"}]}
EOF
$K create ns %{ns} --dry-run=client -o yaml | $K apply -f - >/dev/null
helm -n %{ns} uninstall %{rel} >/dev/null 2>&1 || true
command -v helm >/dev/null || echo "warn: helm not installed"
""",
"checks": [
C("release deployed with at least three revisions of history", 3, r"""
helm -n %{ns} status %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;sys.exit(0 if json.load(sys.stdin)["info"]["status"]=="deployed" else 1)' || exit 1
[ "$(helm -n %{ns} history %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')" -ge 3 ]"""),
C("Deployment is back to 2 replicas", 3, r"""eq "$(j -n %{ns} deploy %{rel}-site '{.spec.replicas}')" 2"""),
C("revision.txt matches the release's current revision", 2, r"""
cur=$(helm -n %{ns} history %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;print(max(r["revision"] for r in json.load(sys.stdin)))' 2>/dev/null)
[ -n "$cur" ] && eq "$(tr -dc '0-9' < "$WS/q6/revision.txt" 2>/dev/null)" "$cur" """),
],
"solution": """
k create ns %{ns}
helm -n %{ns} install %{rel} {WS}/q6/chart --set replicaCount=2
helm -n %{ns} upgrade %{rel} {WS}/q6/chart --set replicaCount=4
helm -n %{ns} history %{rel}
helm -n %{ns} rollback %{rel} 1 # creates a NEW revision identical to revision 1
helm -n %{ns} history %{rel} | grep '^[0-9]' | tail -1 | awk '{print $1}' > {WS}/q6/revision.txt
""",
},
# ------------------------------------------------------------------- OBS ----
{
"id": 7, "domain": "OBS", "title": "Three kinds of probe",
"params": lambda r: {"ns": ns_pick(r, "probe"), "dep": name_pick(r, "api")},
"task": """
Deployment `%{dep}` in `%{ns}` (nginx:1.27, 2 replicas) needs health checks:
- **startup**: HTTP GET `/` on port 80, `periodSeconds: 5`, allowed a total of 60 seconds to come up
- **readiness**: HTTP GET `/` on port 80, every 5 seconds, a single failure removes it from endpoints
- **liveness**: TCP on port 80, every 10 seconds, initial delay 15 seconds
All replicas must end up Ready.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.27 --replicas=2 >/dev/null
""",
"checks": [
C("startupProbe: httpGet / :80, period 5, failureThreshold 12", 3, r"""
c='{.spec.template.spec.containers[0].startupProbe'
eq "$(j -n %{ns} deploy %{dep} "$c.httpGet.path}")" / && eq "$(j -n %{ns} deploy %{dep} "$c.periodSeconds}")" 5 && eq "$(j -n %{ns} deploy %{dep} "$c.failureThreshold}")" 12"""),
C("readinessProbe: httpGet, period 5, failureThreshold 1", 3, r"""
c='{.spec.template.spec.containers[0].readinessProbe'
eq "$(j -n %{ns} deploy %{dep} "$c.httpGet.path}")" / && eq "$(j -n %{ns} deploy %{dep} "$c.periodSeconds}")" 5 && eq "$(j -n %{ns} deploy %{dep} "$c.failureThreshold}")" 1"""),
C("livenessProbe: tcpSocket 80, period 10, initialDelay 15", 3, r"""
c='{.spec.template.spec.containers[0].livenessProbe'
eq "$(j -n %{ns} deploy %{dep} "$c.tcpSocket.port}")" 80 && eq "$(j -n %{ns} deploy %{dep} "$c.periodSeconds}")" 10 && eq "$(j -n %{ns} deploy %{dep} "$c.initialDelaySeconds}")" 15"""),
C("all replicas Ready with the three probes in place", 2, r"""
c='{.spec.template.spec.containers[0]'
for p in startupProbe readinessProbe livenessProbe; do [ -n "$(j -n %{ns} deploy %{dep} "$c.$p}")" ] || exit 1; done
for i in $(seq 1 20); do
eq "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}/{.status.readyReplicas}/{.status.replicas}')" 2/2/2 && exit 0; sleep 3
done; exit 1"""),
],
"solution": """
k -n %{ns} edit deploy %{dep} # add to the container:
# startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12}
# readinessProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 1}
# livenessProbe: {tcpSocket: {port: 80}, periodSeconds: 10, initialDelaySeconds: 15}
k -n %{ns} rollout status deploy %{dep}
# 12 x 5s = 60s. While a startupProbe runs, liveness and readiness are suspended, which is why
# it beats a long initialDelaySeconds on liveness for slow starters.
""",
},
{
"id": 8, "domain": "OBS", "title": "Diagnose and fix a broken Pod",
"params": lambda r: {"ns": ns_pick(r, "debug"), "pod": name_pick(r, "worker")},
"task": """
Pod `%{pod}` in `%{ns}` is not running.
1. Write the container's waiting **reason**, as reported in the Pod's status, to `{WS}/q8/reason.txt`.
2. Fix the Pod so it runs. Keep the name, Namespace and command — correct only what is wrong.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q8"; mkdir -p "$WS/q8"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
containers:
- name: worker
image: busybox:1.99-doesnotexist
command: [sh, -c, "echo working; sleep 86400"]
EOF
$K -n %{ns} get pod %{pod} >/dev/null
""",
"checks": [
C("reason.txt records the image pull failure", 3, r"""grep -Eqi 'ImagePull|ErrImagePull|ImagePullBackOff' "$WS/q8/reason.txt" 2>/dev/null"""),
C("pod Running with its original command", 4, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && j -n %{ns} pod %{pod} '{.spec.containers[0].command}' | grep -q sleep"""),
],
"solution": """
k -n %{ns} get pod %{pod}
k -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].state.waiting.reason}'
# ImagePullBackOff -> {WS}/q8/reason.txt
k -n %{ns} describe pod %{pod} | tail -15 # "Failed to pull image ... not found"
k -n %{ns} set image pod/%{pod} worker=busybox:1.36
# `image` is one of the very few mutable fields on a running Pod, so no delete/recreate is needed
# here - the kubelet just pulls the new image and restarts the container. Most other spec fields
# (command, volumes, securityContext) really are immutable, and for those the fix is:
# k -n %{ns} get pod %{pod} -o yaml > fix.yaml # edit, strip status/uid/resourceVersion
# k -n %{ns} delete pod %{pod} --now && k apply -f fix.yaml
""",
},
# ------------------------------------------------------------------- ENV ----
{
"id": 9, "domain": "ENV", "title": "SecurityContext and capabilities",
"params": lambda r: {"ns": ns_pick(r, "sec"), "pod": name_pick(r, "hardened"), "uid": r.choice([1001, 2000, 10001])},
"task": """
Create Pod `%{pod}` in `%{ns}` (image `busybox:1.36`, command `sleep 86400`) that:
- runs as user %{uid} and group %{uid}, and must not run as root,
- has a read-only root filesystem,
- drops **all** capabilities and adds back only `NET_BIND_SERVICE`,
- cannot gain new privileges.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null""",
"checks": [
C("runs as UID %{uid} with runAsNonRoot", 3, r"""
p="$K -n %{ns} get pod %{pod}"
u=$($p -o jsonpath='{.spec.securityContext.runAsUser}' 2>/dev/null); [ -z "$u" ] && u=$($p -o jsonpath='{.spec.containers[0].securityContext.runAsUser}')
eq "$u" %{uid} && $p -o jsonpath='{.spec.securityContext.runAsNonRoot}{.spec.containers[0].securityContext.runAsNonRoot}' | grep -q true"""),
C("readOnlyRootFilesystem and allowPrivilegeEscalation=false", 3, r"""
c='{.spec.containers[0].securityContext'
eq "$(j -n %{ns} pod %{pod} "$c.readOnlyRootFilesystem}")" true && eq "$(j -n %{ns} pod %{pod} "$c.allowPrivilegeEscalation}")" false"""),
C("capabilities drop ALL, add NET_BIND_SERVICE", 3, r"""
c='{.spec.containers[0].securityContext.capabilities'
j -n %{ns} pod %{pod} "$c.drop[*]}" | grep -q ALL && j -n %{ns} pod %{pod} "$c.add[*]}" | grep -q NET_BIND_SERVICE"""),
C("pod Running as that UID", 2, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && $K -n %{ns} exec %{pod} -- id 2>/dev/null | grep -q 'uid=%{uid}'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
securityContext: {runAsUser: %{uid}, runAsGroup: %{uid}, runAsNonRoot: true}
containers:
- name: c
image: busybox:1.36
command: [sleep, "86400"]
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: {drop: [ALL], add: [NET_BIND_SERVICE]}
EOF
k -n %{ns} exec %{pod} -- id
# runAsUser/runAsGroup/runAsNonRoot exist at pod OR container level;
# capabilities, readOnlyRootFilesystem and allowPrivilegeEscalation are container-level only.
""",
},
{
"id": 10, "domain": "ENV", "title": "Projected volume: config, secret and token",
"params": lambda r: {"ns": ns_pick(r, "cfg"), "pod": name_pick(r, "combined")},
"task": """
In `%{ns}`:
1. Create ConfigMap `app-cfg` with `mode=live` and Secret `app-sec` with `apikey=xyz789`.
2. Create Pod `%{pod}` (busybox:1.36, `sleep 86400`) with a **single projected volume** named
`bundle` mounted at `/etc/bundle`, exposing:
- ConfigMap key `mode` as file `mode`,
- Secret key `apikey` as file `apikey`,
- a ServiceAccount token with audience `vault` and expiry 3600 seconds as file `token`.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null
$K -n %{ns} delete cm app-cfg --ignore-not-found >/dev/null; $K -n %{ns} delete secret app-sec --ignore-not-found >/dev/null
""",
"checks": [
C("ConfigMap and Secret hold the right values", 2, r"""
eq "$(j -n %{ns} cm app-cfg '{.data.mode}')" live && eq "$(j -n %{ns} secret app-sec '{.data.apikey}' | base64 -d)" xyz789"""),
C("one projected volume 'bundle' with all three sources", 3, r"""
s=$(j -n %{ns} pod %{pod} '{.spec.volumes[?(@.name=="bundle")].projected.sources}')
echo "$s" | grep -q configMap && echo "$s" | grep -q secret && echo "$s" | grep -q serviceAccountToken"""),
C("token source: audience vault, expiry 3600", 3, r"""
s=$(j -n %{ns} pod %{pod} '{.spec.volumes[?(@.name=="bundle")].projected.sources[*].serviceAccountToken}')
echo "$s" | grep -q '"audience":"vault"' && echo "$s" | grep -q '"expirationSeconds":3600'"""),
C("all three files readable at /etc/bundle", 3, r"""
eq "$($K -n %{ns} exec %{pod} -- cat /etc/bundle/mode 2>/dev/null)" live \
&& eq "$($K -n %{ns} exec %{pod} -- cat /etc/bundle/apikey 2>/dev/null)" xyz789 \
&& [ -n "$($K -n %{ns} exec %{pod} -- cat /etc/bundle/token 2>/dev/null)" ]"""),
],
"solution": """
k -n %{ns} create cm app-cfg --from-literal=mode=live
k -n %{ns} create secret generic app-sec --from-literal=apikey=xyz789
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- name: bundle
projected:
sources:
- configMap: {name: app-cfg, items: [{key: mode, path: mode}]}
- secret: {name: app-sec, items: [{key: apikey, path: apikey}]}
- serviceAccountToken: {audience: vault, expirationSeconds: 3600, path: token}
containers:
- name: c
image: busybox:1.36
command: [sleep, "86400"]
volumeMounts: [{name: bundle, mountPath: /etc/bundle}]
EOF
k -n %{ns} exec %{pod} -- ls /etc/bundle
""",
},
# ------------------------------------------------------------------- NET ----
{
"id": 11, "domain": "NET", "title": "Expose a Deployment two ways",
"params": lambda r: {"ns": ns_pick(r, "expose"), "dep": name_pick(r, "portal"), "np": r.choice([30310, 30320, 30330])},
"task": """
In `%{ns}` Deployment `%{dep}` (nginx:1.27, 2 replicas) serves on container port 80.
1. Expose it internally as ClusterIP Service `%{dep}`, port 8080 → 80.
2. Also expose it as NodePort Service `%{dep}-ext`, port 80 → 80, nodePort %{np}.
3. Create Ingress `%{dep}` (class `nginx`, host `%{dep}.example.com`, path `/` Prefix) pointing at
the **ClusterIP** Service on port 8080.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
$K -n %{ns} delete svc %{dep} %{dep}-ext --ignore-not-found >/dev/null
$K -n %{ns} delete ingress %{dep} --ignore-not-found >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.27 --replicas=2 --port=80 >/dev/null
""",
"checks": [
C("ClusterIP Service 8080 -> 80 with endpoints", 3, r"""
s="$K -n %{ns} get svc %{dep}"
eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" ClusterIP && eq "$($s -o jsonpath='{.spec.ports[0].port}')" 8080 && eq "$($s -o jsonpath='{.spec.ports[0].targetPort}')" 80 \
|| exit 1
# the Pods may still be starting: an endpoint appears once one of them has an address
for i in $(seq 1 20); do
[ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{dep} '{.items[*].endpoints[*].addresses[*]}')" ] && exit 0; sleep 3
done; exit 1"""),
C("NodePort Service on %{np}", 2, r"""
s="$K -n %{ns} get svc %{dep}-ext"
eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" NodePort && eq "$($s -o jsonpath='{.spec.ports[0].nodePort}')" %{np}"""),
C("Ingress routes the host to the ClusterIP Service on 8080", 3, r"""
i="$K -n %{ns} get ingress %{dep}"
eq "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" nginx && eq "$($i -o jsonpath='{.spec.rules[0].host}')" %{dep}.example.com \
&& eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" %{dep} \
&& eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.port.number}')" 8080"""),
],
"solution": """
k -n %{ns} expose deploy %{dep} --port=8080 --target-port=80
k -n %{ns} expose deploy %{dep} --name=%{dep}-ext --type=NodePort --port=80 --target-port=80 $do > ext.yaml
# set spec.ports[0].nodePort: %{np}
k apply -f ext.yaml
k -n %{ns} create ingress %{dep} --class=nginx --rule="%{dep}.example.com/*=%{dep}:8080"
""",
},
{
"id": 12, "domain": "NET", "title": "Restrict a namespace with NetworkPolicy",
"params": lambda r: {"ns": ns_pick(r, "np"), "client": r.choice(["frontend", "gateway", "portal"])},
"task": """
Namespace `%{ns}` runs Pods labelled `app=db` and `app=%{client}`.
Create NetworkPolicy `db-allow` so `app=db` Pods accept ingress **only** from `app=%{client}`
Pods in the same Namespace on TCP 5432, and nothing else. Do not restrict egress.
(kindnet enforces NetworkPolicy here, so a wrong policy really blocks traffic; you are graded
on the object.)
""",
"setup": NS + r"""
mkns %{ns}
for a in db %{client}; do
$K -n %{ns} create deploy $a --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} patch deploy $a -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"app\":\"$a\"}}}}}" >/dev/null
done
$K -n %{ns} delete netpol db-allow --ignore-not-found >/dev/null
""",
"checks": [
C("selects app=db, Ingress only", 2, r"""
n="$K -n %{ns} get netpol db-allow"
eq "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" db && eq "$($n -o jsonpath='{.spec.policyTypes}')" '["Ingress"]'"""),
C("exactly one rule: from app=%{client} on TCP 5432", 4, r"""
$K -n %{ns} get netpol db-allow -o json 2>/dev/null | python3 -c '
import sys,json
i=json.load(sys.stdin)["spec"].get("ingress",[])
ok=len(i)==1 and len(i[0].get("from",[]))==1 and i[0]["from"][0].get("podSelector",{}).get("matchLabels",{}).get("app")=="%{client}" \
and len(i[0].get("ports",[]))==1 and str(i[0]["ports"][0]["port"])=="5432"
sys.exit(0 if ok else 1)'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-allow, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: %{client}}}}]
ports: [{protocol: TCP, port: 5432}]
EOF
""",
},
]
# 13-37: the rest of the curriculum, one or more tasks per competency the starter bank left out.
# 38-57: Simulator Set A, a fixed killer.sh-style mock. Numbering is contiguous - keep it that way.
# 58-64: a second pool task for each objective that had one (see curriculum.py).
# 65-76: Services and Networking up to 17 pool tasks (bank_net.py).
# 77-84: Application Design and Build up to 17 pool tasks (bank_design.py).
# 85-93: Application Deployment up to 17 pool tasks (bank_deploy.py).
# 94-99: Application Observability and Maintenance up to 17 pool tasks (bank_obs.py).
# 100-105: Application Environment, Configuration and Security up to 17 pool tasks (bank_env.py).
from ckad import bank_deploy, bank_design, bank_env, bank_extra, bank_more, bank_net, bank_obs, bank_sim_a # noqa: E402
QUESTIONS = (QUESTIONS + bank_extra.QUESTIONS + bank_sim_a.QUESTIONS + bank_more.QUESTIONS + bank_net.QUESTIONS
+ bank_design.QUESTIONS + bank_deploy.QUESTIONS + bank_obs.QUESTIONS + bank_env.QUESTIONS)
# Tasks held back from every session, id -> why. None today; the engine drops whatever is listed
# here from sessions, sets and the objective map.
PENDING = {}
# Worker nodes for tasks whose cluster needs more (or fewer) than the track's default. id -> workers
WORKERS = {
15: 1, # DaemonSet on every node: the control-plane node only keeps its taint when there is a worker
}