kubestronaut-sim/ckad/bank_extra.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

1600 lines
83 KiB
Python

"""CKAD tasks 13-37: one or more per curriculum competency the starter bank (1-12) left uncovered.
Same format as questions.py. Parameters (%{...}) are drawn per session; every setup is safe to
re-run, and every check reads the live cluster (or the files the task asks for).
"""
from sim.bank import C, NS, ns_pick, name_pick
QUESTIONS = [
# ---------------------------------------------------------------- DESIGN ----
{
"id": 13, "domain": "DESIGN", "title": "Build, change and export a container image",
"params": lambda r: {"img": name_pick(r, r.choice(["ticker", "beacon", "courier"])),
"greet": r.choice(["hola", "ciao", "salut", "hej"])},
"task": """
The build context for a small image is in `{WS}/q13/image` (a `Dockerfile` and `app.sh`).
Use `podman` on this machine.
1. Build the image, unchanged, as `localhost/%{img}:v1`.
2. Change the **Dockerfile** so the image sets `GREETING=%{greet}` and a new environment
variable `APP_VERSION=v2`, then build it as `localhost/%{img}:v2`. `v1` must stay as it was.
3. Save `localhost/%{img}:v2` as an **OCI archive** to `{WS}/q13/%{img}-v2.tar`.
4. Run `v2` once and write its output to `{WS}/q13/run.txt`.
""",
"setup": r"""
rm -rf "$WS/q13"; mkdir -p "$WS/q13/image"
command -v podman >/dev/null || { echo "podman is not installed on this machine - the task needs it"; exit 1; }
podman rmi -f localhost/%{img}:v1 localhost/%{img}:v2 >/dev/null 2>&1 || true
cat > "$WS/q13/image/Dockerfile" <<'EOF'
FROM docker.io/library/busybox:1.36
ENV GREETING=hello
COPY app.sh /app/app.sh
CMD ["sh", "/app/app.sh"]
EOF
cat > "$WS/q13/image/app.sh" <<'EOF'
echo "${GREETING} from %{img} ${APP_VERSION:-unversioned}"
EOF
""",
"checks": [
C("v1 exists and is the unchanged build (GREETING=hello, no APP_VERSION)", 2, r"""
env=$(podman image inspect localhost/%{img}:v1 --format '{{json .Config.Env}}' 2>/dev/null) || exit 1
echo "$env" | grep -q '"GREETING=hello"' && ! echo "$env" | grep -q APP_VERSION"""),
C("v2 sets GREETING=%{greet} and APP_VERSION=v2", 2, r"""
env=$(podman image inspect localhost/%{img}:v2 --format '{{json .Config.Env}}' 2>/dev/null) || exit 1
echo "$env" | grep -q '"GREETING=%{greet}"' && echo "$env" | grep -q '"APP_VERSION=v2"'"""),
C("the change is in the Dockerfile itself", 1, r"""
f="$WS/q13/image/Dockerfile"
grep -Eq '^ENV.*APP_VERSION[= ]+"?v2' "$f" && grep -Eq '^ENV.*GREETING[= ]+"?%{greet}' "$f" """),
C("OCI archive of v2 at {WS}/q13/%{img}-v2.tar", 2, r"""
f="$WS/q13/%{img}-v2.tar"; [ -s "$f" ] || exit 1
tar -xOf "$f" oci-layout >/dev/null 2>&1 || exit 1
tar -xOf "$f" index.json 2>/dev/null | grep -q '"org.opencontainers.image.ref.name":"localhost/%{img}:v2"'"""),
C("run.txt holds v2's output", 1, r"""tr -d '\r' < "$WS/q13/run.txt" 2>/dev/null | grep -qx '%{greet} from %{img} v2'"""),
],
"solution": """
cd {WS}/q13/image
podman build -t localhost/%{img}:v1 . # build BEFORE editing: v1 must be the original
vi Dockerfile # ENV GREETING=%{greet}
# ENV APP_VERSION=v2
podman build -t localhost/%{img}:v2 .
podman images | grep %{img}
podman save --format oci-archive -o {WS}/q13/%{img}-v2.tar localhost/%{img}:v2
podman run --rm localhost/%{img}:v2 > {WS}/q13/run.txt # %{greet} from %{img} v2
# `podman save` writes docker-archive unless told otherwise; "OCI format" means
# --format oci-archive (an index.json + oci-layout + blobs/ tarball). With docker:
# docker build -t ... . && docker save -o x.tar ... (Docker Engine 25+ writes an OCI layout
# alongside the legacy manifest.json) - on the exam, read which tool and which format the task names. `--env`/`--build-arg` at build time would not change the
# Dockerfile, which is what "change the Dockerfile" asks for.
""",
},
{
"id": 14, "domain": "DESIGN", "title": "Parallel batch Job",
"params": lambda r: {"ns": ns_pick(r, "batch"), "job": name_pick(r, "render"), "n": r.choice([6, 8]), "p": r.choice([2, 3])},
"task": """
In Namespace `%{ns}` create a Job `%{job}` that renders %{n} frames:
- container name `render`, image `busybox:1.36`, command `sh -c 'echo frame done; sleep 3'`
- %{n} successful completions in total, at most %{p} Pods running at the same time
- give up after 2 failed retries
- kill the whole Job if it is still running after 5 minutes
- its Pods carry the label `batch=%{job}`
The Job must finish successfully.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete job %{job} --ignore-not-found --wait=true >/dev/null""",
"checks": [
C("completions %{n}, parallelism %{p}, backoffLimit 2", 3, r"""
s="$K -n %{ns} get job %{job}"
eq "$($s -o jsonpath='{.spec.completions}' 2>/dev/null)" %{n} && eq "$($s -o jsonpath='{.spec.parallelism}')" %{p} && eq "$($s -o jsonpath='{.spec.backoffLimit}')" 2"""),
C("activeDeadlineSeconds 300", 1, r"""eq "$(j -n %{ns} job %{job} '{.spec.activeDeadlineSeconds}')" 300"""),
C("container render (busybox:1.36), Pod label batch=%{job}", 1, r"""
eq "$(j -n %{ns} job %{job} '{.spec.template.spec.containers[0].name}')" render \
&& eq "$(j -n %{ns} job %{job} '{.spec.template.spec.containers[0].image}')" busybox:1.36 \
&& eq "$(j -n %{ns} job %{job} '{.spec.template.metadata.labels.batch}')" %{job}"""),
C("Job complete: %{n} succeeded", 3, r"""
for i in $(seq 1 40); do [ "$(j -n %{ns} job %{job} '{.status.succeeded}')" = %{n} ] && exit 0; sleep 3; done; exit 1"""),
],
"solution": """
k -n %{ns} create job %{job} --image=busybox:1.36 $do -- sh -c 'echo frame done; sleep 3' > job.yaml
vi job.yaml
# spec.completions: %{n}
# spec.parallelism: %{p}
# spec.backoffLimit: 2
# spec.activeDeadlineSeconds: 300
# spec.template.metadata.labels: {batch: %{job}}
# container name: render (create job names the container after the Job)
k apply -f job.yaml
k -n %{ns} get job,pod -l batch=%{job} -w # %{p} at a time until %{n}/%{n}
# A Job's pod template is immutable: if you got a field wrong, delete the Job and apply again.
""",
},
{
"id": 15, "domain": "DESIGN", "title": "DaemonSet on every node",
"params": lambda r: {"ns": ns_pick(r, "fleet"), "ds": name_pick(r, "node-agent")},
"task": """
Create a DaemonSet `%{ds}` in Namespace `%{ns}` that runs one Pod on **every** node of the
cluster, the control plane included:
- image `busybox:1.36`, command
`sh -c 'while true; do echo "$(date) $NODE_NAME ok"; sleep 30; done'`
- environment variable `NODE_NAME` filled from the Pod's own `spec.nodeName`
- requests cpu `10m`, memory `16Mi`
- Pod label `app=%{ds}`
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete ds %{ds} --ignore-not-found --wait=true >/dev/null""",
"checks": [
C("one ready Pod per node", 3, r"""
n=$($K get nodes --no-headers 2>/dev/null | wc -l)
for i in $(seq 1 20); do
[ "$(j -n %{ns} ds %{ds} '{.status.numberReady}')" = "$n" ] && [ "$(j -n %{ns} ds %{ds} '{.status.desiredNumberScheduled}')" = "$n" ] && exit 0; sleep 3
done; exit 1"""),
C("a Pod runs on the control-plane node", 2, r"""j -n %{ns} pod -l app=%{ds} '{.items[*].spec.nodeName}' | tr ' ' '\n' | grep -qx "$CP" """),
C("NODE_NAME comes from spec.nodeName (and shows in the logs)", 2, r"""
eq "$(j -n %{ns} ds %{ds} '{.spec.template.spec.containers[0].env[?(@.name=="NODE_NAME")].valueFrom.fieldRef.fieldPath}')" spec.nodeName || exit 1
p=$(j -n %{ns} pod -l app=%{ds} '{.items[0].metadata.name}'); n=$(j -n %{ns} pod "$p" '{.spec.nodeName}')
[ -n "$n" ] && $K -n %{ns} logs "$p" --tail=5 2>/dev/null | grep -q " $n ok" """),
C("requests cpu 10m / memory 16Mi", 1, r"""
eq "$(j -n %{ns} ds %{ds} '{.spec.template.spec.containers[0].resources.requests.cpu}')" 10m \
&& eq "$(j -n %{ns} ds %{ds} '{.spec.template.spec.containers[0].resources.requests.memory}')" 16Mi"""),
],
"solution": """
k -n %{ns} create deploy %{ds} --image=busybox:1.36 $do -- sh -c 'while true; do echo "$(date) $NODE_NAME ok"; sleep 30; done' > ds.yaml
vi ds.yaml
# kind: Deployment -> DaemonSet; delete replicas, strategy and status
# container:
# env: [{name: NODE_NAME, valueFrom: {fieldRef: {fieldPath: spec.nodeName}}}]
# resources: {requests: {cpu: 10m, memory: 16Mi}}
# pod spec:
# tolerations: [{key: node-role.kubernetes.io/control-plane, operator: Exists, effect: NoSchedule}]
k apply -f ds.yaml
k -n %{ns} get ds,pod -o wide # DESIRED = number of nodes
# There is no `kubectl create daemonset`; starting from a Deployment and changing the kind is the
# fast way. Without the toleration the control-plane node's NoSchedule taint keeps it at 3 of 4.
""",
},
{
"id": 16, "domain": "DESIGN", "title": "Native sidecar lets a Job finish",
"params": lambda r: {"ns": ns_pick(r, "nightly"), "job": name_pick(r, "export")},
"task": """
Job `%{job}` in Namespace `%{ns}` never completes: its log-shipping container `shipper` keeps
running after the `worker` container has finished. Its manifest is `{WS}/q16/job.yaml`.
Keep `shipper` - it must still start **before** `worker` and receive all of its output - but make
it a **native sidecar** container, so the Job completes by itself once `worker` is done.
Keep the Job's name. The Job must end up Complete.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q16"; mkdir -p "$WS/q16"
cat > "$WS/q16/job.yaml" <<'EOF'
apiVersion: batch/v1
kind: Job
metadata: {name: %{job}, namespace: %{ns}}
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
volumes: [{name: data, emptyDir: {}}]
containers:
- name: worker
image: busybox:1.36
command: [sh, -c, "for i in 1 2 3 4 5; do echo \"batch line $i\" >> /data/out.log; sleep 2; done"]
volumeMounts: [{name: data, mountPath: /data}]
- name: shipper
image: busybox:1.36
command: [sh, -c, "touch /data/out.log; tail -F /data/out.log"]
volumeMounts: [{name: data, mountPath: /data}]
EOF
$K -n %{ns} delete job %{job} --ignore-not-found --wait=true >/dev/null
$K apply -f "$WS/q16/job.yaml" >/dev/null
""",
"checks": [
C("shipper is a native sidecar (init container with restartPolicy Always)", 3, r"""
eq "$(j -n %{ns} job %{job} '{.spec.template.spec.initContainers[?(@.name=="shipper")].restartPolicy}')" Always"""),
C("worker is the only regular container", 1, r"""eq "$(j -n %{ns} job %{job} '{.spec.template.spec.containers[*].name}')" worker"""),
C("Job Complete", 3, r"""
for i in $(seq 1 30); do [ "$(j -n %{ns} job %{job} '{.status.succeeded}')" = 1 ] && exit 0; sleep 3; done; exit 1"""),
C("shipper, now a sidecar, received all of worker's output", 1, r"""
eq "$(j -n %{ns} job %{job} '{.spec.template.spec.initContainers[?(@.name=="shipper")].restartPolicy}')" Always \
&& $K -n %{ns} logs job/%{job} -c shipper 2>/dev/null | grep -q 'batch line 5'"""),
],
"solution": """
k -n %{ns} get job,pod # 0/1 complete; the Pod sits at 1/2 Running forever
# A Job counts a Pod as done only when ALL its containers have exited, and `tail -F` never does.
# A native sidecar is an init container with restartPolicy: Always - it starts before the main
# containers, keeps running beside them, and is stopped automatically once they have finished.
vi {WS}/q16/job.yaml
# spec.template.spec:
# initContainers:
# - name: shipper
# restartPolicy: Always # <- this line is what makes it a sidecar
# image: busybox:1.36
# command: [sh, -c, "touch /data/out.log; tail -F /data/out.log"]
# volumeMounts: [{name: data, mountPath: /data}]
# containers: [ worker only ]
k -n %{ns} delete job %{job} # a Job's pod template is immutable
k apply -f {WS}/q16/job.yaml
k -n %{ns} get job %{job} -w # 1/1
k -n %{ns} logs job/%{job} -c shipper # batch line 1..5
""",
},
{
"id": 17, "domain": "DESIGN", "title": "Persistent and ephemeral volumes",
"params": lambda r: {"ns": ns_pick(r, "store"), "dep": name_pick(r, "journal"), "pvc": name_pick(r, "journal-data")},
"task": """
In Namespace `%{ns}`:
1. Create a PersistentVolumeClaim `%{pvc}`: `100Mi`, `ReadWriteOnce`, StorageClass `standard`.
2. Create Deployment `%{dep}` (1 replica, image `busybox:1.36`) whose container runs
`sh -c 'date >> /data/boot.log; sleep 86400'` and mounts
- the claim `%{pvc}` at `/data`,
- an `emptyDir` volume named `scratch` at `/scratch`, held in memory and capped at `32Mi`.
3. Delete the Deployment's Pod once, so that `/data/boot.log` in the replacement Pod has two lines.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} delete pvc %{pvc} --ignore-not-found --wait=true >/dev/null
""",
"checks": [
C("claim %{pvc} Bound: 100Mi, ReadWriteOnce, class standard", 2, r"""
p="$K -n %{ns} get pvc %{pvc}"
eq "$($p -o jsonpath='{.status.phase}' 2>/dev/null)" Bound && eq "$($p -o jsonpath='{.spec.resources.requests.storage}')" 100Mi \
&& eq "$($p -o jsonpath='{.spec.accessModes[0]}')" ReadWriteOnce && eq "$($p -o jsonpath='{.spec.storageClassName}')" standard"""),
C("Deployment mounts the claim at /data", 2, r"""
v=$(j -n %{ns} deploy %{dep} '{.spec.template.spec.volumes[?(@.persistentVolumeClaim.claimName=="%{pvc}")].name}'); [ -n "$v" ] || exit 1
eq "$(j -n %{ns} deploy %{dep} "{.spec.template.spec.containers[0].volumeMounts[?(@.name==\"$v\")].mountPath}")" /data"""),
C("emptyDir scratch: medium Memory, sizeLimit 32Mi, at /scratch", 2, r"""
d='{.spec.template.spec'
eq "$(j -n %{ns} deploy %{dep} "$d.volumes[?(@.name==\"scratch\")].emptyDir.medium}")" Memory \
&& eq "$(j -n %{ns} deploy %{dep} "$d.volumes[?(@.name==\"scratch\")].emptyDir.sizeLimit}")" 32Mi \
&& eq "$(j -n %{ns} deploy %{dep} "$d.containers[0].volumeMounts[?(@.name==\"scratch\")].mountPath}")" /scratch"""),
C("boot.log survived a Pod replacement (2+ lines)", 2, r"""
n=$($K -n %{ns} exec deploy/%{dep} -- sh -c 'wc -l < /data/boot.log' 2>/dev/null | tr -dc '0-9'); [ "${n:-0}" -ge 2 ]"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec: {accessModes: [ReadWriteOnce], storageClassName: standard, resources: {requests: {storage: 100Mi}}}
EOF
k -n %{ns} create deploy %{dep} --image=busybox:1.36 $do -- sh -c 'date >> /data/boot.log; sleep 86400' > d.yaml
vi d.yaml
# pod spec:
# volumes:
# - {name: data, persistentVolumeClaim: {claimName: %{pvc}}}
# - {name: scratch, emptyDir: {medium: Memory, sizeLimit: 32Mi}}
# container:
# volumeMounts: [{name: data, mountPath: /data}, {name: scratch, mountPath: /scratch}]
k apply -f d.yaml
k -n %{ns} get pvc # Pending until the Pod exists: WaitForFirstConsumer
k -n %{ns} delete pod -l app=%{dep}
k -n %{ns} exec deploy/%{dep} -- cat /data/boot.log # two lines: the PVC outlived the Pod
# The emptyDir lives exactly as long as the Pod; medium: Memory makes it tmpfs, and what is
# written there counts against the container's memory limit.
""",
},
# ---------------------------------------------------------------- DEPLOY ----
{
"id": 18, "domain": "DEPLOY", "title": "Canary by replica ratio",
"params": lambda r: {"ns": ns_pick(r, "canary"), "app": r.choice(["checkout", "search", "catalog"])},
"task": """
Namespace `%{ns}` runs Deployment `%{app}-stable` (3 replicas, `nginx:1.25`) behind Service `%{app}`.
Start a canary of `nginx:1.27` that gets **20%** of the Service's traffic by replica count:
- a new Deployment `%{app}-canary` whose Pods the existing Service already selects, with the
label `track=canary`,
- the Service must end up with exactly **5** ready endpoints, **1** of them a canary Pod,
- do not change the Service.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{app}-stable %{app}-canary --ignore-not-found --wait=true >/dev/null
$K -n %{ns} delete svc %{app} --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{app}-stable, namespace: %{ns}}
spec:
replicas: 3
selector: {matchLabels: {app: %{app}, track: stable}}
template:
metadata: {labels: {app: %{app}, track: stable}}
spec: {containers: [{name: web, image: nginx:1.25, ports: [{containerPort: 80}]}]}
---
apiVersion: v1
kind: Service
metadata: {name: %{app}, namespace: %{ns}}
spec: {selector: {app: %{app}}, ports: [{port: 80, targetPort: 80}]}
EOF
""",
"checks": [
C("canary Deployment: nginx:1.27, 1 ready, label track=canary", 2, r"""
d="$K -n %{ns} get deploy %{app}-canary"
eq "$($d -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null)" nginx:1.27 \
&& eq "$($d -o jsonpath='{.spec.template.metadata.labels.track}')" canary && eq "$($d -o jsonpath='{.status.readyReplicas}')" 1"""),
C("stable scaled to 4 and still on nginx:1.25", 2, r"""
eq "$(j -n %{ns} deploy %{app}-stable '{.status.readyReplicas}')" 4 && eq "$(j -n %{ns} deploy %{app}-stable '{.spec.template.spec.containers[0].image}')" nginx:1.25"""),
C("Service unchanged: still selects only app=%{app}", 1, r"""
[ -n "$(j -n %{ns} deploy %{app}-canary '{.metadata.name}')" ] && eq "$(j -n %{ns} svc %{app} '{.spec.selector}')" '{"app":"%{app}"}'"""),
C("Service has 5 ready endpoints, exactly 1 of them canary", 3, r"""
ips=$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{app} '{range .items[*].endpoints[?(@.conditions.ready==true)]}{.addresses[0]}{" "}{end}')
[ "$(echo $ips | wc -w)" = 5 ] || exit 1
can=" $(j -n %{ns} pod -l app=%{app},track=canary '{.items[*].status.podIP}') "; c=0
for ip in $ips; do case "$can" in *" $ip "*) c=$((c+1));; esac; done; [ "$c" = 1 ]"""),
],
"solution": """
k -n %{ns} get deploy %{app}-stable -o yaml > canary.yaml
vi canary.yaml
# metadata.name: %{app}-canary, replicas: 1, image: nginx:1.27
# track: stable -> track: canary in BOTH spec.selector.matchLabels and the template labels
# delete status, uid, resourceVersion, creationTimestamp, generation
k apply -f canary.yaml
k -n %{ns} scale deploy %{app}-stable --replicas=4
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{app} # 5 addresses
# The Service selects only app=%{app}, so it load-balances over stable AND canary Pods in
# proportion to their ready replicas: 4 + 1 = 20% canary. Promote by shifting the ratio;
# roll back by scaling the canary to 0 - the Service never changes.
""",
},
{
"id": 19, "domain": "DEPLOY", "title": "Rolling update without losing capacity",
"params": lambda r: {"ns": ns_pick(r, "roll"), "dep": name_pick(r, "payments")},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` (4 replicas) must never lose capacity during an update.
1. Change its update strategy so that during a rollout **no** Pod is ever unavailable and **at
most one** extra Pod is created, and a new Pod only counts as available once it has been Ready
for **10 seconds**. Keep at most **3** old ReplicaSets around.
2. Then update the image to `nginx:1.27` and let the rollout finish.
""",
"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=4 >/dev/null
$K -n %{ns} rollout status deploy %{dep} --timeout=150s >/dev/null 2>&1
""",
"checks": [
C("RollingUpdate with maxUnavailable 0 and maxSurge 1", 3, r"""
s='{.spec.strategy'
eq "$(j -n %{ns} deploy %{dep} "$s.type}")" RollingUpdate || exit 1
case "$(j -n %{ns} deploy %{dep} "$s.rollingUpdate.maxUnavailable}")" in 0|0%) ;; *) exit 1;; esac
case "$(j -n %{ns} deploy %{dep} "$s.rollingUpdate.maxSurge}")" in 1|25%) ;; *) exit 1;; esac"""),
C("minReadySeconds 10 and revisionHistoryLimit 3", 2, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.minReadySeconds}')" 10 && eq "$(j -n %{ns} deploy %{dep} '{.spec.revisionHistoryLimit}')" 3"""),
C("rolled out: 4/4 updated and available on nginx:1.27", 3, r"""
d="$K -n %{ns} get deploy %{dep}"
eq "$($d -o jsonpath='{.spec.template.spec.containers[0].image}')" nginx:1.27 || exit 1
for i in $(seq 1 30); do
[ "$($d -o jsonpath='{.status.updatedReplicas}/{.status.availableReplicas}/{.status.replicas}')" = 4/4/4 ] \
&& [ "$($d -o jsonpath='{.status.observedGeneration}')" = "$($d -o jsonpath='{.metadata.generation}')" ] && exit 0; sleep 3
done; exit 1"""),
],
"solution": """
k -n %{ns} patch deploy %{dep} -p '{"spec":{"minReadySeconds":10,"revisionHistoryLimit":3,
"strategy":{"type":"RollingUpdate","rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}'
k -n %{ns} set image deploy %{dep} nginx=nginx:1.27
k -n %{ns} rollout status deploy %{dep} # one new Pod at a time, 10 s apart
# maxUnavailable: 0 + maxSurge: 1 means "add one, wait until it is available, remove one old".
# minReadySeconds is what "available" waits for; it also slows every rollout down by design.
""",
},
{
"id": 20, "domain": "DEPLOY", "title": "Kustomize overlay",
"params": lambda r: {"ns": ns_pick(r, "prod"), "n": r.choice([2, 3])},
"task": """
A Kustomize base for an app called `web` is in `{WS}/q20/base`. Do **not** edit the base.
Create an overlay in `{WS}/q20/overlays/prod` that
- puts everything into Namespace `%{ns}`,
- prefixes every resource name with `prod-`,
- runs **%{n}** replicas,
- uses image `nginx:1.27`,
- adds the label `env=prod` to every resource.
Apply the overlay with kubectl's built-in Kustomize.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy prod-web --ignore-not-found >/dev/null; $K -n %{ns} delete svc prod-web --ignore-not-found >/dev/null
rm -rf "$WS/q20"; mkdir -p "$WS/q20/base"
cat > "$WS/q20/base/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: web, labels: {app: 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/q20/base/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata: {name: web, labels: {app: web}}
spec: {selector: {app: web}, ports: [{port: 80, targetPort: 80}]}
EOF
cat > "$WS/q20/base/kustomization.yaml" <<'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: [deployment.yaml, service.yaml]
EOF
(cd "$WS/q20/base" && md5sum *) > "$WS/.q20-base.md5"
""",
"checks": [
C("overlay renders prod-web in %{ns}: %{n} replicas, nginx:1.27, env=prod on everything", 3, r"""
$K kustomize "$WS/q20/overlays/prod" 2>/dev/null | $K create --dry-run=client -o json -f - 2>/dev/null | python3 -c '
import sys, json
s = sys.stdin.read(); dec = json.JSONDecoder(); i = 0; items = []
while i < len(s):
while i < len(s) and s[i].isspace(): i += 1
if i >= len(s): break
o, i = dec.raw_decode(s, i); items.extend(o.get("items", [o]))
dep = [x for x in items if x["kind"] == "Deployment"]
ok = len(items) == 2 and len(dep) == 1 and dep[0]["metadata"]["name"] == "prod-web" \
and all(x["metadata"].get("namespace") == "%{ns}" for x in items) \
and dep[0]["spec"]["replicas"] == %{n} and dep[0]["spec"]["template"]["spec"]["containers"][0]["image"] == "nginx:1.27" \
and all(x["metadata"].get("labels", {}).get("env") == "prod" for x in items)
sys.exit(0 if ok else 1)'"""),
C("applied: Deployment prod-web (%{n} available, nginx:1.27, env=prod) and Service prod-web", 3, r"""
d="$K -n %{ns} get deploy prod-web"
eq "$($d -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null)" nginx:1.27 && eq "$($d -o jsonpath='{.metadata.labels.env}')" prod || exit 1
eq "$(j -n %{ns} svc prod-web '{.metadata.labels.env}')" prod || exit 1
for i in $(seq 1 30); do [ "$($d -o jsonpath='{.status.availableReplicas}')" = %{n} ] && exit 0; sleep 3; done; exit 1"""),
C("base left untouched", 1, r"""
[ -f "$WS/q20/overlays/prod/kustomization.yaml" ] && (cd "$WS/q20/base" && md5sum -c --quiet "$WS/.q20-base.md5" >/dev/null 2>&1)"""),
],
"solution": """
mkdir -p {WS}/q20/overlays/prod
cat > {WS}/q20/overlays/prod/kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: [../../base]
namespace: %{ns}
namePrefix: prod-
labels:
- pairs: {env: prod}
includeSelectors: false
replicas:
- {name: web, count: %{n}}
images:
- {name: nginx, newTag: "1.27"}
EOF
k kustomize {WS}/q20/overlays/prod # always look at the render first
k apply -k {WS}/q20/overlays/prod
k -n %{ns} get deploy,svc -l env=prod
# `labels` replaced the deprecated `commonLabels`. includeSelectors: false keeps env=prod out of
# the Deployment's selector, which is immutable once created. `replicas` and `images` refer to the
# base's names (web, nginx) - the prefix is applied after them.
""",
},
{
"id": 21, "domain": "DEPLOY", "title": "Helm: values file and an upgrade that keeps values",
"params": lambda r: {"ns": ns_pick(r, "charts"), "rel": name_pick(r, "shop"), "old": name_pick(r, "catalog")},
"task": """
Two versions of the chart `webapp` are packaged in `{WS}/q21/charts/`.
1. Write the default values of chart version **1.3.0** to `{WS}/q21/defaults-1.3.0.yaml`.
2. Install release `%{rel}` into Namespace `%{ns}` from `webapp-1.3.0.tgz`, using a values file
`{WS}/q21/%{rel}-values.yaml` that you write, setting `replicaCount: 2` and
`service.type: NodePort`.
3. Namespace `%{ns}` already has a release running chart version **1.2.0**. Upgrade it to 1.3.0
**keeping the values it was installed with**.
""",
"setup": NS + r"""
command -v helm >/dev/null || { echo "helm is not installed on this machine - the task needs it"; exit 1; }
mkns %{ns}
for r in $(helm -n %{ns} list -aq 2>/dev/null || helm -n %{ns} list -q 2>/dev/null); do helm -n %{ns} uninstall "$r" >/dev/null 2>&1; done
rm -rf "$WS/q21"; mkdir -p "$WS/q21/charts"
src=$(mktemp -d)
for v in 1.2.0 1.3.0; do
d="$src/$v/webapp"; mkdir -p "$d/templates"
tag=1.25; [ "$v" = 1.3.0 ] && tag=1.27
printf 'apiVersion: v2\nname: webapp\nversion: %s\nappVersion: "%s"\n' "$v" "$tag" > "$d/Chart.yaml"
printf 'replicaCount: 1\nimage:\n repository: nginx\n tag: "%s"\nservice:\n type: ClusterIP\n port: 80\n' "$tag" > "$d/values.yaml"
cat > "$d/templates/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-webapp
labels: {app.kubernetes.io/instance: {{ .Release.Name }}, chart: {{ .Chart.Name }}-{{ .Chart.Version }}}
spec:
replicas: {{ .Values.replicaCount }}
selector: {matchLabels: {app.kubernetes.io/instance: {{ .Release.Name }}}}
template:
metadata: {labels: {app.kubernetes.io/instance: {{ .Release.Name }}}}
spec:
containers:
- name: web
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports: [{containerPort: 80}]
EOF
cat > "$d/templates/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata: {name: {{ .Release.Name }}-webapp}
spec:
type: {{ .Values.service.type }}
selector: {app.kubernetes.io/instance: {{ .Release.Name }}}
ports: [{port: {{ .Values.service.port }}, targetPort: 80}]
EOF
helm package "$d" -d "$WS/q21/charts" >/dev/null
done
rm -rf "$src"
helm -n %{ns} install %{old} "$WS/q21/charts/webapp-1.2.0.tgz" --set replicaCount=3 >/dev/null
""",
"checks": [
C("defaults-1.3.0.yaml is the chart's default values", 1, r"""
f="$WS/q21/defaults-1.3.0.yaml"; [ -s "$f" ] && diff -Bw <(helm show values "$WS/q21/charts/webapp-1.3.0.tgz" 2>/dev/null) "$f" >/dev/null"""),
C("%{rel} deployed from webapp-1.3.0 with replicaCount 2 and a NodePort Service", 3, r"""
helm -n %{ns} list -o json 2>/dev/null | python3 -c '
import sys, json
r = {x["name"]: x for x in json.load(sys.stdin)}.get("%{rel}") or {}
sys.exit(0 if r.get("chart") == "webapp-1.3.0" and r.get("status") == "deployed" else 1)' || exit 1
helm -n %{ns} get values %{rel} -o json 2>/dev/null | python3 -c '
import sys, json
d = json.load(sys.stdin) or {}
sys.exit(0 if d.get("replicaCount") == 2 and (d.get("service") or {}).get("type") == "NodePort" else 1)' || exit 1
eq "$(j -n %{ns} svc %{rel}-webapp '{.spec.type}')" NodePort || exit 1
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy %{rel}-webapp '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""),
C("the values file sets both", 1, r"""
f="$WS/q21/%{rel}-values.yaml"; grep -Eq '^replicaCount: *2 *$' "$f" 2>/dev/null && grep -Eq '^ +type: *"?NodePort"? *$' "$f" """),
C("%{old} upgraded to webapp-1.3.0 and still runs 3 replicas", 3, r"""
helm -n %{ns} list -o json 2>/dev/null | python3 -c '
import sys, json
r = {x["name"]: x for x in json.load(sys.stdin)}.get("%{old}") or {}
sys.exit(0 if r.get("chart") == "webapp-1.3.0" and r.get("status") == "deployed" and int(r.get("revision", 0)) >= 2 else 1)' || exit 1
for i in $(seq 1 20); do [ "$(j -n %{ns} deploy %{old}-webapp '{.spec.replicas}/{.status.availableReplicas}')" = 3/3 ] && exit 0; sleep 3; done; exit 1"""),
],
"solution": """
helm show values {WS}/q21/charts/webapp-1.3.0.tgz > {WS}/q21/defaults-1.3.0.yaml
cat > {WS}/q21/%{rel}-values.yaml <<EOF
replicaCount: 2
service:
type: NodePort
EOF
helm -n %{ns} install %{rel} {WS}/q21/charts/webapp-1.3.0.tgz -f {WS}/q21/%{rel}-values.yaml
helm -n %{ns} list # the release on webapp-1.2.0 is %{old}
helm -n %{ns} get values %{old} # USER-SUPPLIED VALUES: replicaCount: 3
helm -n %{ns} upgrade %{old} {WS}/q21/charts/webapp-1.3.0.tgz --reset-then-reuse-values
helm -n %{ns} list # %{old}: webapp-1.3.0, revision 2
# A plain `helm upgrade` starts again from the new chart's defaults: %{old} would silently drop to
# 1 replica. --reuse-values keeps the old values but ALSO the old chart's defaults (image tag
# 1.25 here); --reset-then-reuse-values (Helm 3.14+) takes the new defaults and re-applies only
# what the user supplied. `-f` with the output of `helm get values` works everywhere.
""",
},
# ------------------------------------------------------------------- OBS ----
{
"id": 22, "domain": "OBS", "title": "Manifests written for removed APIs",
"params": lambda r: {"ns": ns_pick(r, "legacy"), "app": name_pick(r, "portal")},
"task": """
`{WS}/q22/legacy.yaml` was written for an old cluster and no longer applies here
(`kubectl apply` fails with "no matches for kind").
1. Write every `apiVersion` in that file that this cluster does not serve to
`{WS}/q22/removed.txt`, one per line.
2. Save a corrected copy as `{WS}/q22/fixed.yaml` that uses API versions this cluster serves and
keeps each object's behaviour (routing, schedule, budget, scaling limits).
3. Apply `fixed.yaml`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q22"; mkdir -p "$WS/q22"
$K -n %{ns} delete ingress/%{app} cronjob/%{app}-report pdb/%{app} hpa/%{app} --ignore-not-found >/dev/null 2>&1
$K -n %{ns} create deploy %{app} --image=nginx:1.27 --replicas=2 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} expose deploy %{app} --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
cat > "$WS/q22/legacy.yaml" <<'EOF'
apiVersion: extensions/v1beta1
kind: Ingress
metadata: {name: %{app}, namespace: %{ns}}
spec:
rules:
- host: %{app}.example.com
http:
paths:
- path: /
backend: {serviceName: %{app}, servicePort: 80}
---
apiVersion: batch/v1beta1
kind: CronJob
metadata: {name: %{app}-report, namespace: %{ns}}
spec:
schedule: "15 2 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers: [{name: report, image: busybox:1.36, command: [sh, -c, "echo report"]}]
---
apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata: {name: %{app}, namespace: %{ns}}
spec:
minAvailable: 1
selector: {matchLabels: {app: %{app}}}
---
apiVersion: autoscaling/v2beta2
kind: HorizontalPodAutoscaler
metadata: {name: %{app}, namespace: %{ns}}
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: %{app}}
minReplicas: 2
maxReplicas: 5
metrics:
- type: Resource
resource: {name: cpu, target: {type: Utilization, averageUtilization: 70}}
EOF
""",
"checks": [
C("removed.txt lists exactly the four removed versions", 2, r"""
python3 - "$WS/q22/removed.txt" <<'PY'
import sys
want = {"extensions/v1beta1", "batch/v1beta1", "policy/v1beta1", "autoscaling/v2beta2"}
try:
got = {l.strip() for l in open(sys.argv[1]) if l.strip()}
except OSError:
sys.exit(1)
sys.exit(0 if got == want else 1)
PY"""),
C("Ingress %{app}: host %{app}.example.com, / -> Service %{app}:80", 2, r"""
i="$K -n %{ns} get ingress %{app}"
eq "$($i -o jsonpath='{.spec.rules[0].host}' 2>/dev/null)" %{app}.example.com \
&& eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].path}')" / \
&& eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" %{app} \
&& eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.port.number}')" 80"""),
C("CronJob %{app}-report on '15 2 * * *' and PDB %{app} minAvailable 1", 2, r"""
eq "$(j -n %{ns} cronjob %{app}-report '{.spec.schedule}')" "15 2 * * *" \
&& eq "$(j -n %{ns} pdb %{app} '{.spec.minAvailable}/{.spec.selector.matchLabels.app}')" 1/%{app}"""),
C("HPA %{app}: 2-5 replicas at 70% CPU", 1, r"""
eq "$(j -n %{ns} hpa %{app} '{.spec.minReplicas}/{.spec.maxReplicas}/{.spec.metrics[0].resource.target.averageUtilization}/{.spec.scaleTargetRef.name}')" 2/5/70/%{app}"""),
C("fixed.yaml holds all four objects and applies cleanly", 1, r"""
f="$WS/q22/fixed.yaml"; [ "$(grep -c '^kind:' "$f" 2>/dev/null)" -ge 4 ] && $K apply --dry-run=server -f "$f" >/dev/null 2>&1"""),
],
"solution": """
k apply -f {WS}/q22/legacy.yaml --dry-run=server # no matches for kind "Ingress" in version "extensions/v1beta1" ...
k api-resources | grep -Ei 'ingresses|cronjobs|poddisruptionbudgets|horizontalpodautoscalers' # APIVERSION column
k explain ingress.spec.rules.http.paths.backend # the v1 backend shape
cp {WS}/q22/legacy.yaml {WS}/q22/fixed.yaml && vi {WS}/q22/fixed.yaml
# extensions/v1beta1 Ingress -> networking.k8s.io/v1, and the backend changes shape:
# - path: /
# pathType: Prefix (now required)
# backend: {service: {name: %{app}, port: {number: 80}}}
# batch/v1beta1 CronJob -> batch/v1 spec unchanged
# policy/v1beta1 PDB -> policy/v1 spec unchanged
# autoscaling/v2beta2 HPA -> autoscaling/v2 spec unchanged
printf '%s\\n' extensions/v1beta1 batch/v1beta1 policy/v1beta1 autoscaling/v2beta2 > {WS}/q22/removed.txt
k apply -f {WS}/q22/fixed.yaml
# The `kubectl convert` plugin can do the rewrite, but it is not installed by default - the
# Ingress backend change is the one to know by heart.
""",
},
{
"id": 23, "domain": "OBS", "title": "Find the heaviest Pods with kubectl top",
"params": lambda r: dict(zip(("cpu", "mem", "idle"),
[f"{x}-{r.randint(10, 99)}" for x in r.sample(["orders", "billing", "search", "profile", "ledger"], 3)]),
ns=ns_pick(r, "watch")),
"task": """
Namespace `%{ns}` runs three Pods. Using the metrics API (metrics-server is installed):
1. Write the name of the Pod using the most **CPU** to `{WS}/q23/cpu.txt`.
2. Write the name of the Pod using the most **memory** to `{WS}/q23/memory.txt`.
3. Write a command that shows the current CPU and memory usage of every **node** to
`{WS}/q23/nodes.sh`.
4. Write a command that lists the Pods of **all** Namespaces sorted by memory usage to
`{WS}/q23/pods.sh`.
""",
"setup": NS + r"""
ensure_metrics_server || echo "warn: metrics-server could not be installed (no network?) - kubectl top will not work"
mkns %{ns}; rm -rf "$WS/q23"; mkdir -p "$WS/q23"
$K -n %{ns} delete pod %{cpu} %{mem} %{idle} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: %{cpu}, namespace: %{ns}, labels: {app: watch}}
spec:
containers:
- name: main
image: busybox:1.36
command: [sh, -c, "timeout 7200 sh -c 'while :; do :; done'; sleep 86400"]
resources: {requests: {cpu: 50m, memory: 16Mi}, limits: {cpu: 200m, memory: 64Mi}}
---
apiVersion: v1
kind: Pod
metadata: {name: %{mem}, namespace: %{ns}, labels: {app: watch}}
spec:
volumes: [{name: cache, emptyDir: {medium: Memory, sizeLimit: 160Mi}}]
containers:
- name: main
image: busybox:1.36
command: [sh, -c, "dd if=/dev/zero of=/cache/fill bs=1M count=120 2>/dev/null; sleep 86400"]
volumeMounts: [{name: cache, mountPath: /cache}]
resources: {requests: {cpu: 10m, memory: 160Mi}, limits: {cpu: 100m, memory: 256Mi}}
---
apiVersion: v1
kind: Pod
metadata: {name: %{idle}, namespace: %{ns}, labels: {app: watch}}
spec:
containers:
- name: main
image: busybox:1.36
command: [sleep, "86400"]
resources: {requests: {cpu: 10m, memory: 16Mi}, limits: {cpu: 100m, memory: 64Mi}}
EOF
$K -n %{ns} get pod %{cpu} %{mem} %{idle} >/dev/null
""",
"checks": [
C("cpu.txt names %{cpu}", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q23/cpu.txt" 2>/dev/null)" %{cpu}"""),
C("memory.txt names %{mem}", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q23/memory.txt" 2>/dev/null)" %{mem}"""),
C("nodes.sh uses kubectl top for nodes", 1, r"""grep -Eq '(kubectl|k) +top +(node|nodes|no)( |$)' "$WS/q23/nodes.sh" 2>/dev/null"""),
C("pods.sh: top pods of all namespaces sorted by memory", 1, r"""
f="$WS/q23/pods.sh"; grep -Eq '(kubectl|k) +top +(pod|pods|po)( |$)' "$f" 2>/dev/null \
&& grep -Eq '(-A( |$)|--all-namespaces)' "$f" && grep -Eq -- '--sort-by[= ]memory' "$f" """),
],
"solution": """
k top pod -n %{ns} # give metrics-server ~30 s after the Pods start
k top pod -n %{ns} --sort-by=cpu --no-headers | head -1 | awk '{print $1}' > {WS}/q23/cpu.txt
k top pod -n %{ns} --sort-by=memory --no-headers | head -1 | awk '{print $1}' > {WS}/q23/memory.txt
echo 'kubectl top node' > {WS}/q23/nodes.sh
echo 'kubectl top pod -A --sort-by=memory' > {WS}/q23/pods.sh
# `kubectl top` reads the metrics API (metrics.k8s.io), served by metrics-server; without it you get
# "Metrics API not available". `--containers` breaks a Pod's usage down per container.
""",
},
{
"id": 24, "domain": "OBS", "title": "Logs of a container that keeps crashing",
"params": lambda r: {"ns": ns_pick(r, "ingest"), "pod": name_pick(r, "ingest"), "cfg": r.choice(["pipeline", "routes", "sinks", "tenants"])},
"task": """
Pod `%{pod}` in Namespace `%{ns}` has two containers, and one of them keeps restarting.
1. Save the complete log of the **previous** (crashed) run of the failing container to
`{WS}/q24/crash.log`.
2. Write the full path of the file that the crashed run could not open to `{WS}/q24/missing.txt`.
3. Save the last **3** log lines of the other container, **with timestamps**, to
`{WS}/q24/helper.log`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q24"; mkdir -p "$WS/q24"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
containers:
- name: ingest
image: busybox:1.36
command: [sh, -c, "echo \"ingest starting (pid $$)\"; echo \"loading configuration\"; sleep 12; echo \"FATAL: cannot open /etc/ingest/%{cfg}.yaml: no such file or directory\"; exit 1"]
- name: helper
image: busybox:1.36
command: [sh, -c, "while true; do echo \"helper tick $(date +%s)\"; sleep 5; done"]
EOF
$K -n %{ns} get pod %{pod} >/dev/null
""",
"checks": [
C("crash.log is one full crashed run of container ingest", 3, r"""
f="$WS/q24/crash.log"; grep -q 'ingest starting' "$f" 2>/dev/null && grep -q 'FATAL: cannot open /etc/ingest/%{cfg}.yaml' "$f" """),
C("missing.txt holds /etc/ingest/%{cfg}.yaml", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q24/missing.txt" 2>/dev/null)" /etc/ingest/%{cfg}.yaml"""),
C("helper.log: its last 3 lines, timestamped", 2, r"""
f="$WS/q24/helper.log"; [ "$(grep -c . "$f" 2>/dev/null)" = 3 ] \
&& [ "$(grep -Ec '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z helper tick [0-9]+$' "$f")" = 3 ]"""),
],
"solution": """
k -n %{ns} get pod %{pod} # 1/2, RESTARTS climbing: container ingest
k -n %{ns} logs %{pod} -c ingest --previous > {WS}/q24/crash.log
cat {WS}/q24/crash.log # FATAL: cannot open /etc/ingest/%{cfg}.yaml ...
echo /etc/ingest/%{cfg}.yaml > {WS}/q24/missing.txt
k -n %{ns} logs %{pod} -c helper --tail=3 --timestamps > {WS}/q24/helper.log
# Without -c, logs of a multi-container Pod asks you to choose a container. --previous reads the
# last terminated instance - while the new one is still running it is the only place the crash
# message exists. `describe pod` shows Last State: Terminated, Exit Code: 1 as well.
# For a few seconds right after a crash (STATUS Error, before CrashLoopBackOff) --previous can fail
# with "unable to retrieve container logs": it points at the run BEFORE the one that just died, and
# that one is already garbage-collected. Plain `logs` shows the crashed run at that moment - read
# STATUS first, or wait a few seconds and try --previous again.
""",
},
{
"id": 25, "domain": "OBS", "title": "Two faults behind a stuck Deployment",
"params": lambda r: {"ns": ns_pick(r, "fix"), "dep": name_pick(r, "catalog"), "cm": name_pick(r, "catalog-config"),
"lvl": r.choice(["info", "warn", "debug"])},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` has 0 of 2 Pods ready.
Fix it so both replicas are Running and Ready. Do not modify ConfigMap `%{cm}`: the container must
keep taking its `LOG_LEVEL` environment variable from that ConfigMap.
Then write the value the containers see for `LOG_LEVEL` to `{WS}/q25/log-level.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q25"; mkdir -p "$WS/q25"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} delete cm %{cm} --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata: {name: %{cm}, namespace: %{ns}}
data: {log_level: %{lvl}, cache_size: "256"}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: web
image: nginx:1.27
command: [nginxx, -g, "daemon off;"]
env:
- name: LOG_LEVEL
valueFrom: {configMapKeyRef: {name: %{cm}, key: log-level}}
EOF
$K -n %{ns} get cm %{cm} -o jsonpath='{.metadata.resourceVersion}' > "$WS/.q25-cm-rv"
""",
"checks": [
C("2/2 Pods Ready", 3, r"""
for i in $(seq 1 30); do [ "$(j -n %{ns} deploy %{dep} '{.status.readyReplicas}/{.status.updatedReplicas}/{.status.replicas}')" = 2/2/2 ] && exit 0; sleep 3; done; exit 1"""),
C("LOG_LEVEL still comes from %{cm} (key log_level) and reads %{lvl}", 2, r"""
e='{.spec.template.spec.containers[0].env[?(@.name=="LOG_LEVEL")].valueFrom.configMapKeyRef'
eq "$(j -n %{ns} deploy %{dep} "$e.name}")" %{cm} && eq "$(j -n %{ns} deploy %{dep} "$e.key}")" log_level \
&& eq "$($K -n %{ns} exec deploy/%{dep} -- printenv LOG_LEVEL 2>/dev/null)" %{lvl}"""),
C("ConfigMap %{cm} untouched", 1, r"""
[ "$(j -n %{ns} deploy %{dep} '{.metadata.generation}')" -gt 1 ] 2>/dev/null && eq "$(j -n %{ns} cm %{cm} '{.metadata.resourceVersion}')" "$(cat "$WS/.q25-cm-rv" 2>/dev/null)" """),
C("log-level.txt holds %{lvl}", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q25/log-level.txt" 2>/dev/null)" %{lvl}"""),
],
"solution": """
k -n %{ns} get pod # CreateContainerConfigError
k -n %{ns} events --types=Warning # couldn't find key log-level in ConfigMap %{ns}/%{cm}
# (kubectl 1.36+ describe drops Events when a selector matches several Pods - add --show-events)
k -n %{ns} get cm %{cm} -o yaml # the key is log_level (underscore)
k -n %{ns} edit deploy %{dep} # key: log-level -> key: log_level
k -n %{ns} get pod # now RunContainerError / CrashLoopBackOff
k -n %{ns} describe pod -l app=%{dep} | grep -iE 'reason|message' | head # exec: "nginxx": executable file not found
k -n %{ns} edit deploy %{dep} # delete the command: line (the image's own entrypoint runs nginx)
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} exec deploy/%{dep} -- printenv LOG_LEVEL > {WS}/q25/log-level.txt
# The second fault only shows once the first is fixed: a container whose config cannot be built is
# never started, so its command is never tried. Fix, re-read the status, repeat.
""",
},
{
"id": 26, "domain": "OBS", "title": "OOMKilled",
"params": lambda r: {"ns": ns_pick(r, "media"), "dep": name_pick(r, "thumbnailer")},
"task": """
Pods of Deployment `%{dep}` in Namespace `%{ns}` keep restarting.
1. Write the reason the container was last terminated, exactly as Kubernetes reports it, to
`{WS}/q26/reason.txt`.
2. Fix it by raising only the container's **memory limit**, to `256Mi` - leave its requests
alone. The new Pod must stay up without restarting.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q26"; mkdir -p "$WS/q26"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 1
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: resize
image: busybox:1.36
command: [sh, -c, "dd if=/dev/zero of=/dev/null bs=150M count=1 2>/dev/null && echo warmed up; sleep 86400"]
resources: {requests: {cpu: 50m, memory: 32Mi}, limits: {memory: 64Mi}}
EOF
""",
"checks": [
C("reason.txt holds OOMKilled", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q26/reason.txt" 2>/dev/null)" OOMKilled"""),
C("memory limit 256Mi, requests unchanged (cpu 50m, memory 32Mi)", 2, r"""
r='{.spec.template.spec.containers[0].resources'
eq "$(j -n %{ns} deploy %{dep} "$r.limits.memory}")" 256Mi && eq "$(j -n %{ns} deploy %{dep} "$r.requests.memory}")" 32Mi \
&& eq "$(j -n %{ns} deploy %{dep} "$r.requests.cpu}")" 50m"""),
C("the new Pod is Ready and has not restarted", 3, r"""
for i in $(seq 1 40); do
if [ "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}/{.status.availableReplicas}/{.status.replicas}')" = 1/1/1 ] \
&& [ "$(j -n %{ns} pod -l app=%{dep} '{.items[*].status.containerStatuses[0].restartCount}')" = 0 ]; then
[ "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].resources.limits.memory}')" = 256Mi ] && exit 0
fi; sleep 3
done; exit 1"""),
],
"solution": """
k -n %{ns} get pod # RESTARTS climbing, CrashLoopBackOff
k -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[0].status.containerStatuses[0].lastState.terminated.reason}'
# OOMKilled -> {WS}/q26/reason.txt (describe pod: Last State: Terminated, Reason: OOMKilled, Exit Code: 137)
k -n %{ns} set resources deploy %{dep} -c resize --limits=memory=256Mi
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} get pod # 0 restarts
# `set resources --limits` only touches limits; requests stay as they were. The kernel kills a
# container that crosses its memory limit - that is OOMKilled, not a crash in the app itself.
""",
},
# ------------------------------------------------------------------- ENV ----
{
"id": 27, "domain": "ENV", "title": "Discover and use a custom resource",
"params": lambda r: {"ns": ns_pick(r, "dataops"), "name": name_pick(r, "nightly"), "n": r.choice([3, 4, 5]),
"target": r.choice(["orders-db", "billing-db", "users-db"])},
"task": """
An operator's CustomResourceDefinition for **backups** is installed in this cluster.
1. Write the CRD's full name to `{WS}/q27/crd.txt`.
2. Before you create anything, write how many objects of that kind exist in the **whole
cluster** to `{WS}/q27/count.txt`.
3. In Namespace `%{ns}` create one named `%{name}` that backs up `%{target}` every day at 03:30
(cron `30 3 * * *`) and keeps each backup for 14 days. Use the field names the CRD defines.
""",
"setup": NS + r"""
rm -rf "$WS/q27"; mkdir -p "$WS/q27"
$K apply -f - >/dev/null <<'EOF'
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: backups.data.example.com}
spec:
group: data.example.com
scope: Namespaced
names: {plural: backups, singular: backup, kind: Backup, shortNames: [bkp]}
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- {name: Target, type: string, jsonPath: .spec.target}
- {name: Schedule, type: string, jsonPath: .spec.schedule}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [target, schedule, retentionDays]
properties:
target: {type: string, description: "Name of the database to back up"}
schedule: {type: string, description: "Cron expression"}
retentionDays: {type: integer, minimum: 1, maximum: 90, description: "Days each backup is kept"}
EOF
$K wait --for=condition=Established crd/backups.data.example.com --timeout=60s >/dev/null
mkns %{ns}; mkns %{ns}-archive
$K delete backups.data.example.com --all -A --wait=true >/dev/null 2>&1
i=0
for spec in "orders-db|0 1 * * *|7" "users-db|30 1 * * *|30" "billing-db|0 2 * * 0|90" "search-db|15 4 * * *|14" "audit-db|0 5 1 * *|60"; do
i=$((i+1)); [ "$i" -gt %{n} ] && break
t=${spec%%|*}; rest=${spec#*|}; s=${rest%%|*}; d=${rest#*|}
bns=%{ns}-archive; [ "$i" = 1 ] && bns=%{ns}
printf 'apiVersion: data.example.com/v1\nkind: Backup\nmetadata: {name: %s-weekly, namespace: %s}\nspec: {target: %s, schedule: "%s", retentionDays: %s}\n' \
"$t" "$bns" "$t" "$s" "$d" | $K apply -f - >/dev/null
done
""",
"checks": [
C("crd.txt holds backups.data.example.com", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q27/crd.txt" 2>/dev/null)" backups.data.example.com"""),
C("count.txt holds %{n}", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q27/count.txt" 2>/dev/null)" %{n}"""),
C("Backup %{name}: target %{target}, schedule 30 3 * * *, retentionDays 14", 3, r"""
b="$K -n %{ns} get backups.data.example.com %{name}"
eq "$($b -o jsonpath='{.spec.target}' 2>/dev/null)" %{target} && eq "$($b -o jsonpath='{.spec.schedule}')" "30 3 * * *" \
&& eq "$($b -o jsonpath='{.spec.retentionDays}')" 14"""),
],
"solution": """
k get crd | grep -i backup # backups.data.example.com
echo backups.data.example.com > {WS}/q27/crd.txt
k api-resources --api-group=data.example.com # kind Backup, short name bkp, namespaced
k get backups -A --no-headers | wc -l > {WS}/q27/count.txt
k explain backup.spec # target, schedule, retentionDays (all required)
cat <<EOF | k apply -f -
apiVersion: data.example.com/v1
kind: Backup
metadata: {name: %{name}, namespace: %{ns}}
spec: {target: %{target}, schedule: "30 3 * * *", retentionDays: 14}
EOF
k -n %{ns} get bkp
# A CRD's schema is enforced by the API server: a wrong field name (retention, days...) is
# dropped or rejected, which is why `kubectl explain` on the kind comes first.
""",
},
{
"id": 28, "domain": "ENV", "title": "RBAC for a CI ServiceAccount",
"params": lambda r: {"ns": ns_pick(r, "team"), "sa": name_pick(r, "deployer")},
"task": """
In Namespace `%{ns}`, create ServiceAccount `%{sa}` for a CI job that must be able to:
- get, list and watch Pods, and read their logs,
- get, list, update and patch Deployments,
- scale Deployments,
in this Namespace only - and nothing else. Use a Role `%{sa}-role` and a RoleBinding
`%{sa}-binding`. Check the result with `kubectl auth can-i ... --as`.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete rolebinding %{sa}-binding --ignore-not-found >/dev/null; $K -n %{ns} delete role %{sa}-role --ignore-not-found >/dev/null
$K -n %{ns} delete sa %{sa} --ignore-not-found >/dev/null
""",
"checks": [
C("Role %{sa}-role bound to ServiceAccount %{sa} by %{sa}-binding", 1, r"""
b="$K -n %{ns} get rolebinding %{sa}-binding"
eq "$($b -o jsonpath='{.roleRef.kind}/{.roleRef.name}' 2>/dev/null)" Role/%{sa}-role \
&& eq "$($b -o jsonpath='{.subjects[0].kind}/{.subjects[0].name}/{.subjects[0].namespace}')" ServiceAccount/%{sa}/%{ns}"""),
C("can get, list and watch Pods and read pods/log", 2, r"""
a="--as=system:serviceaccount:%{ns}:%{sa} -n %{ns}"
for v in get list watch; do eq "$($K auth can-i $v pods $a 2>/dev/null)" yes || exit 1; done
eq "$($K auth can-i get pods --subresource=log $a 2>/dev/null)" yes"""),
C("can get, list, update, patch and scale Deployments", 2, r"""
a="--as=system:serviceaccount:%{ns}:%{sa} -n %{ns}"
for v in get list update patch; do eq "$($K auth can-i $v deployments.apps $a 2>/dev/null)" yes || exit 1; done
eq "$($K auth can-i patch deployments.apps --subresource=scale $a 2>/dev/null)" yes || eq "$($K auth can-i update deployments.apps --subresource=scale $a 2>/dev/null)" yes"""),
C("nothing else: no deleting Pods, creating Deployments, reading Secrets, or other Namespaces", 2, r"""
a="--as=system:serviceaccount:%{ns}:%{sa}"
eq "$($K auth can-i list pods $a -n %{ns} 2>/dev/null)" yes || exit 1
for t in "delete pods -n %{ns}" "create deployments.apps -n %{ns}" "list secrets -n %{ns}" "list pods -n default" "delete deployments.apps -n %{ns}"; do
eq "$($K auth can-i $t $a 2>/dev/null)" no || exit 1
done"""),
],
"solution": """
k -n %{ns} create sa %{sa}
k -n %{ns} create role %{sa}-role --verb=get,list,watch --resource=pods,pods/log $do > role.yaml
vi role.yaml # add a second rule:
# - apiGroups: [apps]
# resources: [deployments, deployments/scale]
# verbs: [get, list, update, patch]
k apply -f role.yaml
k -n %{ns} create rolebinding %{sa}-binding --role=%{sa}-role --serviceaccount=%{ns}:%{sa}
k auth can-i watch pods -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} # yes
k auth can-i get pods --subresource=log -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} # yes
k auth can-i patch deployments --subresource=scale -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} # yes
k auth can-i delete pods -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} # no
# Subresources are separate resources to RBAC: `pods` does not include `pods/log`, and
# `deployments` does not include `deployments/scale` (what `kubectl scale` calls).
# One `create role` can only hold one rule's verbs, hence the edit.
""",
},
{
"id": 29, "domain": "ENV", "title": "Pod Security admission blocks a Deployment",
"params": lambda r: {"ns": ns_pick(r, "secure"), "dep": name_pick(r, "vault-agent"), "uid": r.choice([1000, 2000, 3000])},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` shows `0/2` and none of its Pods exist.
1. Find out why, and write the Pod Security level the Namespace enforces to
`{WS}/q29/level.txt`.
2. Fix the **Deployment** (leave the Namespace as it is) so both replicas run. Its container must
run as user `%{uid}`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q29"; mkdir -p "$WS/q29"
$K label ns %{ns} pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest --overwrite >/dev/null
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null 2>&1 <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: agent
image: busybox:1.36
command: [sh, -c, "echo agent up as $(id -u); sleep 86400"]
EOF
""",
"checks": [
C("level.txt holds restricted", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q29/level.txt" 2>/dev/null)" restricted"""),
C("2/2 available and the Namespace still enforces restricted", 4, r"""
eq "$(j ns %{ns} '{.metadata.labels.pod-security\.kubernetes\.io/enforce}')" restricted || exit 1
for i in $(seq 1 30); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""),
C("the container runs as UID %{uid}", 2, r"""eq "$($K -n %{ns} exec deploy/%{dep} -- id -u 2>/dev/null)" %{uid}"""),
],
"solution": """
k -n %{ns} get deploy,rs,pod # desired 2, current 0
k -n %{ns} describe rs -l app=%{dep} | tail -5
# Error creating: pods ... is forbidden: violates PodSecurity "restricted:latest":
# allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile ...
k get ns %{ns} --show-labels # pod-security.kubernetes.io/enforce=restricted
echo restricted > {WS}/q29/level.txt
k -n %{ns} edit deploy %{dep}
# template.spec:
# securityContext: {runAsNonRoot: true, runAsUser: %{uid}, seccompProfile: {type: RuntimeDefault}}
# containers[0].securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}}
k -n %{ns} get deploy %{dep} -w
# Admission rejects the Pods, not the Deployment, so the only symptom on the Deployment is 0/2 -
# the reason is in the ReplicaSet's events. busybox runs as root by default, so runAsNonRoot
# alone is not enough: it needs a non-zero runAsUser too.
""",
},
{
"id": 30, "domain": "ENV", "title": "Quota with LimitRange defaults",
"params": lambda r: {"ns": ns_pick(r, "capped"), "dep": name_pick(r, "api")},
"task": """
Namespace `%{ns}` must be capped:
1. ResourceQuota `compute-quota`: `requests.cpu` 1, `requests.memory` 1Gi, `limits.cpu` 2,
`limits.memory` 2Gi, and at most 6 Pods.
2. LimitRange `container-defaults`: a container that declares no resources gets requests
cpu `100m` / memory `128Mi` and limits cpu `250m` / memory `256Mi`; no container may set a
limit above cpu `500m` / memory `512Mi`.
3. Create Deployment `%{dep}` (`nginx:1.27`, 3 replicas) **without** a resources section. It must run.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} delete resourcequota --all >/dev/null 2>&1; $K -n %{ns} delete limitrange --all >/dev/null 2>&1
""",
"checks": [
C("ResourceQuota compute-quota: 1 / 1Gi requests, 2 / 2Gi limits, 6 pods", 2, r"""
$K -n %{ns} get resourcequota compute-quota -o json 2>/dev/null | python3 -c '
import sys, json
h = json.load(sys.stdin)["spec"]["hard"]
want = {"requests.cpu": "1", "requests.memory": "1Gi", "limits.cpu": "2", "limits.memory": "2Gi", "pods": "6"}
sys.exit(0 if all(h.get(k) == v for k, v in want.items()) else 1)'"""),
C("LimitRange container-defaults: defaults and maximums", 2, r"""
$K -n %{ns} get limitrange container-defaults -o json 2>/dev/null | python3 -c '
import sys, json
c = [l for l in json.load(sys.stdin)["spec"]["limits"] if l.get("type") == "Container"]
ok = len(c) == 1 and c[0].get("defaultRequest") == {"cpu": "100m", "memory": "128Mi"} \
and c[0].get("default") == {"cpu": "250m", "memory": "256Mi"} and c[0].get("max") == {"cpu": "500m", "memory": "512Mi"}
sys.exit(0 if ok else 1)'"""),
C("Deployment %{dep}: 3 available, no resources of its own, Pods got the defaults", 2, r"""
[ -n "$(j -n %{ns} deploy %{dep} '{.metadata.name}')" ] || exit 1
[ -z "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].resources.requests}{.spec.template.spec.containers[0].resources.limits}')" ] || exit 1
for i in $(seq 1 30); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 3 ] && break; sleep 3; done
eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 \
&& eq "$(j -n %{ns} pod -l app=%{dep} '{.items[0].spec.containers[0].resources.requests.cpu}/{.items[0].spec.containers[0].resources.limits.memory}')" 100m/256Mi"""),
C("the quota counts the three Pods (300m CPU requested)", 1, r"""
eq "$(j -n %{ns} resourcequota compute-quota '{.status.used.requests\.cpu}/{.status.used.pods}')" 300m/3"""),
],
"solution": """
k -n %{ns} create quota compute-quota --hard=requests.cpu=1,requests.memory=1Gi,limits.cpu=2,limits.memory=2Gi,pods=6
cat <<EOF | k apply -f -
apiVersion: v1
kind: LimitRange
metadata: {name: container-defaults, namespace: %{ns}}
spec:
limits:
- type: Container
defaultRequest: {cpu: 100m, memory: 128Mi}
default: {cpu: 250m, memory: 256Mi}
max: {cpu: 500m, memory: 512Mi}
EOF
k -n %{ns} create deploy %{dep} --image=nginx:1.27 --replicas=3
k -n %{ns} describe quota compute-quota # used: requests.cpu 300m, pods 3
# With a quota on requests/limits, every Pod MUST state them or it is rejected ("must specify
# limits.cpu..."). The LimitRange's defaults are filled in at admission, before the quota check -
# which is what lets a Deployment without resources run here. Create the LimitRange first: if the
# Deployment came first, its ReplicaSet keeps retrying with a growing backoff (FailedCreate events).
""",
},
{
"id": 31, "domain": "ENV", "title": "Resource requirements for Guaranteed QoS",
"params": lambda r: {"ns": ns_pick(r, "qos"), "dep": name_pick(r, "ledger")},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` runs Pods of QoS class `BestEffort`, the first to be
evicted when a node runs short of memory.
1. Give it QoS class **Guaranteed**: container `app` gets cpu `250m` / memory `128Mi`, container
`metrics` gets cpu `50m` / memory `32Mi`.
2. Once the new Pods run, write the QoS class of one of them to `{WS}/q31/qos.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q31"; mkdir -p "$WS/q31"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- {name: app, image: nginx:1.27}
- {name: metrics, image: busybox:1.36, command: [sleep, "86400"]}
EOF
""",
"checks": [
C("app: cpu 250m / memory 128Mi, requests equal to limits", 2, r"""
r='{.spec.template.spec.containers[?(@.name=="app")].resources'
eq "$(j -n %{ns} deploy %{dep} "$r.limits.cpu}/$r.limits.memory}")" 250m/128Mi || exit 1
q=$(j -n %{ns} deploy %{dep} "$r.requests.cpu}/$r.requests.memory}"); [ "$q" = / ] || [ "$q" = 250m/128Mi ]"""),
C("metrics: cpu 50m / memory 32Mi, requests equal to limits", 2, r"""
r='{.spec.template.spec.containers[?(@.name=="metrics")].resources'
eq "$(j -n %{ns} deploy %{dep} "$r.limits.cpu}/$r.limits.memory}")" 50m/32Mi || exit 1
q=$(j -n %{ns} deploy %{dep} "$r.requests.cpu}/$r.requests.memory}"); [ "$q" = / ] || [ "$q" = 50m/32Mi ]"""),
C("rolled out; its running Pods are Guaranteed", 2, r"""
for i in $(seq 1 30); do [ "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}/{.status.availableReplicas}/{.status.replicas}')" = 2/2/2 ] && break; sleep 3; done
# old Pods linger in Terminating for the grace period (sleep ignores SIGTERM) - judge only live ones
$K -n %{ns} get pod -l app=%{dep} -o json 2>/dev/null | python3 -c '
import sys, json
live = [p for p in json.load(sys.stdin)["items"] if not p["metadata"].get("deletionTimestamp") and p["status"].get("phase") == "Running"]
sys.exit(0 if len(live) == 2 and all(p["status"].get("qosClass") == "Guaranteed" for p in live) else 1)'"""),
C("qos.txt holds Guaranteed", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q31/qos.txt" 2>/dev/null)" Guaranteed"""),
],
"solution": """
k -n %{ns} set resources deploy %{dep} -c app --requests=cpu=250m,memory=128Mi --limits=cpu=250m,memory=128Mi
k -n %{ns} set resources deploy %{dep} -c metrics --requests=cpu=50m,memory=32Mi --limits=cpu=50m,memory=32Mi
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} get pod -l app=%{dep} # wait until the old Pods are gone, not just Terminating:
# `sleep` ignores SIGTERM, so they linger ~30 s, still BestEffort
k -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[0].status.qosClass}' > {WS}/q31/qos.txt
# Guaranteed = EVERY container has cpu and memory limits, and requests equal to them (limits
# alone also works: requests default to the limits). One container short of that makes the whole
# Pod Burstable; nothing set anywhere is BestEffort.
""",
},
{
"id": 32, "domain": "ENV", "title": "ConfigMap as an env var and as a file",
"params": lambda r: {"ns": ns_pick(r, "conf"), "cm": name_pick(r, "app-config"), "pod": name_pick(r, "reader"),
"color": r.choice(["teal", "amber", "violet", "coral"])},
"task": """
In Namespace `%{ns}`:
1. Create ConfigMap `%{cm}` holding the file `{WS}/q32/app.properties` under the key
`app.properties`, plus a key `TIER` with the value `backend`.
2. Create Pod `%{pod}` (`busybox:1.36`, command `sleep 86400`) that
- gets the environment variable `APP_TIER` from the ConfigMap's `TIER` key,
- mounts **only** the `app.properties` key, as the file `/etc/app/app.properties`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q32"; mkdir -p "$WS/q32"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null; $K -n %{ns} delete cm %{cm} --ignore-not-found >/dev/null
printf 'color=%{color}\nretries=3\nmode=strict\n' > "$WS/q32/app.properties"
""",
"checks": [
C("ConfigMap: app.properties matches the file, TIER=backend", 2, r"""
[ "$(j -n %{ns} cm %{cm} '{.data.app\.properties}')" = "$(cat "$WS/q32/app.properties")" ] && eq "$(j -n %{ns} cm %{cm} '{.data.TIER}')" backend"""),
C("APP_TIER comes from key TIER and reads backend", 2, r"""
e='{.spec.containers[0].env[?(@.name=="APP_TIER")].valueFrom.configMapKeyRef'
eq "$(j -n %{ns} pod %{pod} "$e.name}/$e.key}")" %{cm}/TIER && eq "$($K -n %{ns} exec %{pod} -- printenv APP_TIER 2>/dev/null)" backend"""),
C("/etc/app holds only app.properties, with the file's content", 2, r"""
eq "$($K -n %{ns} exec %{pod} -- ls /etc/app 2>/dev/null)" app.properties \
&& [ "$($K -n %{ns} exec %{pod} -- cat /etc/app/app.properties 2>/dev/null)" = "$(cat "$WS/q32/app.properties")" ]"""),
],
"solution": """
k -n %{ns} create cm %{cm} --from-file=app.properties={WS}/q32/app.properties --from-literal=TIER=backend
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- name: cfg
configMap: {name: %{cm}, items: [{key: app.properties, path: app.properties}]}
containers:
- name: reader
image: busybox:1.36
command: [sleep, "86400"]
env: [{name: APP_TIER, valueFrom: {configMapKeyRef: {name: %{cm}, key: TIER}}}]
volumeMounts: [{name: cfg, mountPath: /etc/app}]
EOF
k -n %{ns} exec %{pod} -- sh -c 'echo $APP_TIER; ls /etc/app; cat /etc/app/app.properties'
# Without `items`, every key becomes a file (TIER too). --from-file=KEY=PATH names the key; a
# bare --from-file=PATH uses the file's basename. A volume mount updates when the ConfigMap
# changes (after a short delay); an env var never does - the Pod must be recreated.
""",
},
{
"id": 33, "domain": "ENV", "title": "Secrets: decode one, consume another",
"params": lambda r: {"ns": ns_pick(r, "creds"), "old": name_pick(r, "db-creds"), "sec": name_pick(r, "api-creds"),
"pod": name_pick(r, "client"), "user": r.choice(["svc-orders", "svc-billing", "svc-search"]),
"pw": "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(12)),
"oldpw": "".join(r.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(10))},
"task": """
In Namespace `%{ns}`:
1. Secret `%{old}` already exists. Write its decoded `password` to `{WS}/q33/old-password.txt`.
2. Create Secret `%{sec}` with `username=%{user}` and `password=%{pw}`.
3. Create Pod `%{pod}` (`busybox:1.36`, command `sleep 86400`) that
- gets the environment variable `API_USER` from the Secret's `username`,
- mounts the whole Secret **read-only** at `/etc/api-creds`, its files with mode `0400`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q33"; mkdir -p "$WS/q33"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null
$K -n %{ns} delete secret %{old} %{sec} --ignore-not-found >/dev/null
$K -n %{ns} create secret generic %{old} --from-literal=username=legacy-app --from-literal=password=%{oldpw} >/dev/null
""",
"checks": [
C("old-password.txt holds the decoded password", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q33/old-password.txt" 2>/dev/null)" %{oldpw}"""),
C("Secret %{sec}: username and password", 1, r"""
eq "$(j -n %{ns} secret %{sec} '{.data.username}' | base64 -d 2>/dev/null)" %{user} && eq "$(j -n %{ns} secret %{sec} '{.data.password}' | base64 -d 2>/dev/null)" %{pw}"""),
C("API_USER comes from the Secret and reads %{user}", 2, r"""
e='{.spec.containers[0].env[?(@.name=="API_USER")].valueFrom.secretKeyRef'
eq "$(j -n %{ns} pod %{pod} "$e.name}/$e.key}")" %{sec}/username && eq "$($K -n %{ns} exec %{pod} -- printenv API_USER 2>/dev/null)" %{user}"""),
C("mounted read-only at /etc/api-creds, files mode 0400", 2, r"""
v=$(j -n %{ns} pod %{pod} '{.spec.volumes[?(@.secret.secretName=="%{sec}")].name}'); [ -n "$v" ] || exit 1
eq "$(j -n %{ns} pod %{pod} "{.spec.containers[0].volumeMounts[?(@.name==\"$v\")].mountPath}")" /etc/api-creds || exit 1
eq "$(j -n %{ns} pod %{pod} "{.spec.containers[0].volumeMounts[?(@.name==\"$v\")].readOnly}")" true || exit 1
eq "$($K -n %{ns} exec %{pod} -- cat /etc/api-creds/password 2>/dev/null)" %{pw} \
&& eq "$($K -n %{ns} exec %{pod} -- stat -L -c %a /etc/api-creds/password 2>/dev/null)" 400"""),
],
"solution": """
k -n %{ns} get secret %{old} -o jsonpath='{.data.password}' | base64 -d > {WS}/q33/old-password.txt
k -n %{ns} create secret generic %{sec} --from-literal=username=%{user} --from-literal=password=%{pw}
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- name: creds
secret: {secretName: %{sec}, defaultMode: 0400}
containers:
- name: client
image: busybox:1.36
command: [sleep, "86400"]
env: [{name: API_USER, valueFrom: {secretKeyRef: {name: %{sec}, key: username}}}]
volumeMounts: [{name: creds, mountPath: /etc/api-creds, readOnly: true}]
EOF
k -n %{ns} exec %{pod} -- sh -c 'echo $API_USER; ls -lL /etc/api-creds'
# defaultMode is an octal in YAML (0400) but a decimal in JSON (256); `k get -o yaml` shows 256.
# Secret data is only base64-encoded, not encrypted - `base64 -d` is all it takes to read it.
""",
},
{
"id": 34, "domain": "ENV", "title": "ServiceAccount without an automounted token",
"params": lambda r: {"ns": ns_pick(r, "svcacct"), "sa": name_pick(r, "reporter"), "dep": name_pick(r, "report-api")},
"task": """
In Namespace `%{ns}`:
1. Create ServiceAccount `%{sa}` that does **not** automount an API token into Pods.
2. Create a long-lived API token for it as a Secret named `%{sa}-token`, and write the decoded
token to `{WS}/q34/token.txt`.
3. Create Deployment `%{dep}` (`nginx:1.27`, 1 replica) that runs as `%{sa}`. Its Pods must not
have an API token mounted.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q34"; mkdir -p "$WS/q34"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} delete secret %{sa}-token --ignore-not-found >/dev/null; $K -n %{ns} delete sa %{sa} --ignore-not-found >/dev/null
""",
"checks": [
C("ServiceAccount %{sa} with automountServiceAccountToken: false", 1, r"""eq "$(j -n %{ns} sa %{sa} '{.automountServiceAccountToken}')" false"""),
C("Secret %{sa}-token is a service-account token for %{sa}, issued", 2, r"""
s="$K -n %{ns} get secret %{sa}-token"
eq "$($s -o jsonpath='{.type}' 2>/dev/null)" kubernetes.io/service-account-token \
&& eq "$($s -o jsonpath='{.metadata.annotations.kubernetes\.io/service-account\.name}')" %{sa} && [ -n "$($s -o jsonpath='{.data.token}')" ]"""),
C("token.txt holds the decoded token", 2, r"""
t=$(j -n %{ns} secret %{sa}-token '{.data.token}' | base64 -d 2>/dev/null); [ -n "$t" ] && eq "$(tr -d '[:space:]' < "$WS/q34/token.txt" 2>/dev/null)" "$t" """),
C("Deployment runs as %{sa} with no API token volume", 2, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.serviceAccountName}')" %{sa} || exit 1
for i in $(seq 1 30); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
p=$(j -n %{ns} pod -l app=%{dep} --field-selector=status.phase=Running '{.items[0].metadata.name}'); [ -n "$p" ] || exit 1
eq "$(j -n %{ns} pod "$p" '{.spec.serviceAccountName}')" %{sa} && [ -z "$(j -n %{ns} pod "$p" '{.spec.volumes[*].projected.sources[*].serviceAccountToken.path}')" ]"""),
],
"solution": """
k -n %{ns} create sa %{sa}
k -n %{ns} patch sa %{sa} -p '{"automountServiceAccountToken": false}'
cat <<EOF | k apply -f -
apiVersion: v1
kind: Secret
metadata:
name: %{sa}-token
namespace: %{ns}
annotations: {kubernetes.io/service-account.name: %{sa}}
type: kubernetes.io/service-account-token
EOF
k -n %{ns} get secret %{sa}-token -o jsonpath='{.data.token}' | base64 -d > {WS}/q34/token.txt
k -n %{ns} create deploy %{dep} --image=nginx:1.27 $do > d.yaml
vi d.yaml # template.spec.serviceAccountName: %{sa}
k apply -f d.yaml
k -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[0].spec.volumes}' # no kube-api-access-*
# Since 1.24 a ServiceAccount gets no token Secret on its own; the token controller fills one in
# when you create it with that type and annotation. `kubectl create token %{sa}` makes a
# short-lived token instead, with no Secret at all.
""",
},
# ------------------------------------------------------------------- NET ----
{
"id": 35, "domain": "NET", "title": "Egress lockdown that keeps DNS working",
"params": lambda r: {"ns": ns_pick(r, "mesh")},
"task": """
In Namespace `%{ns}`, Pods labelled `app=api` must be locked down for **outgoing** traffic:
- they may connect to Pods labelled `app=db` on TCP port 80,
- they may resolve names through the cluster DNS (UDP and TCP port 53),
- every other outgoing connection from them must be blocked.
Create NetworkPolicy `api-egress` for this, without restricting incoming traffic. kindnet
enforces NetworkPolicy in this cluster, so it is tested for real from an `api` Pod.
""",
"setup": NS + r"""
mkns %{ns}; mkns %{ns}-ext
$K -n %{ns} delete netpol --all >/dev/null 2>&1
for a in db cache; do
$K -n %{ns} create deploy $a --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} expose deploy $a --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
done
$K -n %{ns} create deploy api --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 | $K apply -f - >/dev/null
$K -n %{ns}-ext create deploy web --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns}-ext expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
for d in api db cache; do $K -n %{ns} rollout status deploy $d --timeout=150s >/dev/null 2>&1; done
""",
"checks": [
C("api-egress selects app=api and restricts Egress only", 1, r"""
eq "$(j -n %{ns} netpol api-egress '{.spec.podSelector.matchLabels.app}')" api && eq "$(j -n %{ns} netpol api-egress '{.spec.policyTypes}')" '["Egress"]'"""),
C("api reaches db by its Service name (DNS + TCP 80)", 3, r"""
[ -n "$(j -n %{ns} netpol api-egress '{.metadata.name}')" ] || exit 1
for i in 1 2 3; do $K -n %{ns} exec deploy/api -- wget -T 3 -qO- http://db 2>/dev/null | grep -qi nginx && exit 0; sleep 2; done; exit 1"""),
# a failed connection proves nothing on its own (a Pod not ready yet fails too): only count it as
# blocked while the policy exists and the allowed path to db demonstrably works
C("api can no longer reach cache", 2, r"""
ok() { $K -n %{ns} exec deploy/api -- wget -T 3 -qO- "$1" >/dev/null 2>&1; }
[ -n "$(j -n %{ns} netpol api-egress '{.metadata.name}')" ] && ok http://db && ! ok http://cache"""),
C("api can no longer reach another Namespace", 1, r"""
ok() { $K -n %{ns} exec deploy/api -- wget -T 3 -qO- "$1" >/dev/null 2>&1; }
[ -n "$(j -n %{ns} netpol api-egress '{.metadata.name}')" ] && ok http://db && ! ok http://web.%{ns}-ext"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: api-egress, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: api}}
policyTypes: [Egress]
egress:
- to: [{podSelector: {matchLabels: {app: db}}}]
ports: [{protocol: TCP, port: 80}]
- ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
EOF
k -n %{ns} exec deploy/api -- wget -T 3 -qO- http://db | head -3 # works
k -n %{ns} exec deploy/api -- wget -T 3 -qO- http://cache # times out
# The second rule has no `to`: port 53 to anywhere. Forget it and even `http://db` fails -
# with a bad address, because the name can no longer be resolved. Policy is checked against the
# Pod IP behind the Service, so the db rule selects Pods, never the Service.
""",
},
{
"id": 36, "domain": "NET", "title": "A Service that routes nowhere",
"params": lambda r: {"ns": ns_pick(r, "svc"), "app": name_pick(r, "inventory")},
"task": """
In Namespace `%{ns}`, `wget http://%{app}` from Pod `probe` fails, although Deployment `%{app}`
has two healthy Pods.
1. Fix Service `%{app}` - not the Deployment - so it serves the Deployment's Pods on port 80.
2. Write the Service's endpoint IP addresses, one per line, to `{WS}/q36/endpoints.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q36"; mkdir -p "$WS/q36"
$K -n %{ns} delete deploy %{app} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} delete svc %{app} --ignore-not-found >/dev/null
$K -n %{ns} delete pod probe --ignore-not-found --wait=true --grace-period=1 >/dev/null
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{app}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{app}, tier: web}}
template:
metadata: {labels: {app: %{app}, tier: web}}
spec:
containers:
- name: web
image: nginx:1.27
ports: [{name: http, containerPort: 80}]
---
apiVersion: v1
kind: Service
metadata: {name: %{app}, namespace: %{ns}}
spec:
selector: {app: %{app}, tier: frontend}
ports: [{name: http, port: 80, targetPort: web}]
---
apiVersion: v1
kind: Pod
metadata: {name: probe, namespace: %{ns}}
spec:
containers: [{name: probe, image: busybox:1.36, command: [sleep, "86400"]}]
EOF
$K -n %{ns} rollout status deploy %{app} --timeout=150s >/dev/null 2>&1
$K -n %{ns} wait --for=condition=Ready pod/probe --timeout=120s >/dev/null # fail the seed loudly if it is missing
""",
"checks": [
C("probe gets the nginx page from http://%{app}", 3, r"""
for i in 1 2 3; do $K -n %{ns} exec probe -- wget -T 3 -qO- http://%{app} 2>/dev/null | grep -qi nginx && exit 0; sleep 2; done; exit 1"""),
C("Deployment untouched, Service still on port 80", 2, r"""
$K -n %{ns} exec probe -- wget -T 3 -qO- http://%{app} >/dev/null 2>&1 || exit 1
eq "$(j -n %{ns} deploy %{app} '{.metadata.generation}')" 1 && eq "$(j -n %{ns} svc %{app} '{.spec.ports[0].port}')" 80"""),
C("endpoints.txt lists exactly the Pods' IPs", 2, r"""
want=$(j -n %{ns} pod -l app=%{app},tier=web --field-selector=status.phase=Running '{range .items[*]}{.status.podIP}{"\n"}{end}' | sort)
[ -n "$want" ] && [ "$(grep -v '^[[:space:]]*$' "$WS/q36/endpoints.txt" 2>/dev/null | tr -d ' \r' | sort)" = "$want" ]"""),
],
"solution": """
k -n %{ns} get svc %{app} -o wide # SELECTOR app=%{app},tier=frontend
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{app} # no endpoints
k -n %{ns} get pod --show-labels # the Pods say tier=web
k -n %{ns} get deploy %{app} -o jsonpath='{.spec.template.spec.containers[0].ports}' # port name: http
k -n %{ns} edit svc %{app}
# selector: tier: frontend -> tier: web
# targetPort: web -> targetPort: http (or 80)
k -n %{ns} exec probe -- wget -T 3 -qO- http://%{app} | head -3
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{app} -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\\n"}{end}' > {WS}/q36/endpoints.txt
# Two faults, one symptom. Fixing the selector gives the Service endpoints, but a named targetPort
# that no container port carries still leaves them unusable - check both, every time.
""",
},
{
"id": 37, "domain": "NET", "title": "Ingress with two paths and TLS",
"params": lambda r: {"ns": ns_pick(r, "edge"), "dom": r.choice(["shop", "store", "market", "bazaar"])},
"task": """
In Namespace `%{ns}`, Services `web` (port 80) and `api` (port 8080) exist.
1. Create a TLS Secret `%{dom}-tls` from `{WS}/q37/tls.crt` and `{WS}/q37/tls.key`.
2. Create Ingress `%{dom}` with IngressClass `nginx` for host `%{dom}.example.com`:
- path `/api` (Prefix) goes to Service `api` on port 8080,
- path `/` (Prefix) goes to Service `web` on port 80,
- TLS for that host uses the Secret from step 1.
(No Ingress controller runs in this cluster; the objects are graded.)
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q37"; mkdir -p "$WS/q37"
$K -n %{ns} delete ingress %{dom} --ignore-not-found >/dev/null; $K -n %{ns} delete secret %{dom}-tls --ignore-not-found >/dev/null
for s in web api; do $K -n %{ns} create deploy $s --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null; done
$K -n %{ns} expose deploy web --port=80 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} expose deploy api --port=8080 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
subj="/CN=%{dom}.example.com"
if command -v openssl >/dev/null; then
openssl req -x509 -nodes -newkey rsa:2048 -days 30 -subj "$subj" -keyout "$WS/q37/tls.key" -out "$WS/q37/tls.crt" >/dev/null 2>&1
else # no openssl on this machine: make the pair on the control-plane node instead
nx "$CP" "openssl req -x509 -nodes -newkey rsa:2048 -days 30 -subj $subj -keyout /tmp/q37.key -out /tmp/q37.crt >/dev/null 2>&1; cat /tmp/q37.crt" > "$WS/q37/tls.crt" </dev/null
nx "$CP" 'cat /tmp/q37.key; rm -f /tmp/q37.key /tmp/q37.crt' > "$WS/q37/tls.key" </dev/null
fi
[ -s "$WS/q37/tls.crt" ] && [ -s "$WS/q37/tls.key" ] || { echo "could not generate the certificate"; exit 1; }
""",
"checks": [
C("TLS Secret %{dom}-tls holds the given certificate and key", 2, r"""
eq "$(j -n %{ns} secret %{dom}-tls '{.type}')" kubernetes.io/tls || exit 1
# read on the host: once the task is seeded the key (mode 600) is the candidate's file
crt=$(nx "$CP" 'cat /opt/course/q37/tls.crt' </dev/null); key=$(nx "$CP" 'cat /opt/course/q37/tls.key' </dev/null)
[ -n "$crt" ] && [ -n "$key" ] || exit 1
[ "$(j -n %{ns} secret %{dom}-tls '{.data.tls\.crt}' | base64 -d 2>/dev/null)" = "$crt" ] \
&& [ "$(j -n %{ns} secret %{dom}-tls '{.data.tls\.key}' | base64 -d 2>/dev/null)" = "$key" ]"""),
C("class nginx; %{dom}.example.com: /api -> api:8080 and / -> web:80, both Prefix", 3, r"""
$K -n %{ns} get ingress %{dom} -o json 2>/dev/null | python3 -c '
import sys, json
s = json.load(sys.stdin)["spec"]
rules = [r for r in s.get("rules", []) if r.get("host") == "%{dom}.example.com"]
paths = {p.get("path"): (p.get("pathType"), p["backend"]["service"]["name"], p["backend"]["service"]["port"].get("number"))
for r in rules for p in r.get("http", {}).get("paths", [])}
ok = s.get("ingressClassName") == "nginx" and paths == {"/api": ("Prefix", "api", 8080), "/": ("Prefix", "web", 80)}
sys.exit(0 if ok else 1)'"""),
C("TLS for %{dom}.example.com uses %{dom}-tls", 2, r"""
$K -n %{ns} get ingress %{dom} -o json 2>/dev/null | python3 -c '
import sys, json
t = json.load(sys.stdin)["spec"].get("tls", [])
sys.exit(0 if any(x.get("secretName") == "%{dom}-tls" and "%{dom}.example.com" in x.get("hosts", []) for x in t) else 1)'"""),
],
"solution": """
k -n %{ns} create secret tls %{dom}-tls --cert={WS}/q37/tls.crt --key={WS}/q37/tls.key
k -n %{ns} create ingress %{dom} --class=nginx \\
--rule="%{dom}.example.com/api*=api:8080,tls=%{dom}-tls" \\
--rule="%{dom}.example.com/*=web:80,tls=%{dom}-tls"
k -n %{ns} get ingress %{dom} -o yaml
# In --rule, a trailing * means pathType Prefix (no * = Exact), and tls=SECRET adds the host to
# spec.tls with that Secret. The YAML equivalent:
# spec:
# ingressClassName: nginx
# tls: [{hosts: [%{dom}.example.com], secretName: %{dom}-tls}]
# rules:
# - host: %{dom}.example.com
# http:
# paths:
# - {path: /api, pathType: Prefix, backend: {service: {name: api, port: {number: 8080}}}}
# - {path: /, pathType: Prefix, backend: {service: {name: web, port: {number: 80}}}}
""",
},
]