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

536 lines
26 KiB
Python

"""CKAD tasks 77-84: Application Design and Build - the pool tasks that take the domain from 9 to 17.
1.1 Images 77 a Dockerfile that does not build 78 what is inside this image?
1.2 Workloads 79 a StatefulSet with storage per replica 80 a Job that retries, then cleans up
1.3 Multi-container 81 an ambassador in front of a backend 82 init containers in the wrong order
1.4 Volumes 83 a claim that stays Pending 84 one file into a directory that has others
Images are built and run on the task's host for real (its podman is the store the grader looks
in); everything else is checked in the running Pods - what a container serves, what a file holds,
whether a name resolves. Every task has a scripted solution in solve.py.
"""
from ckad.common import C, IMG, ONE, PY, NS, ns_pick, name_pick
WORDS = ["amber", "basalt", "cobalt", "dune", "ember", "fjord", "garnet", "harbor"]
QUESTIONS = [
# ========================================================================= 1.1 Images ====
{
"id": 77, "domain": "DESIGN", "title": "A Dockerfile that does not build",
"params": lambda r: {"img": name_pick(r, "ticker"), "word": r.choice(WORDS)},
"task": """
`{WS}/q77/image/` holds a script `run.sh` and a Dockerfile for it that does not build.
Fix the **Dockerfile** — leave `run.sh` alone — so that:
- the base image is `busybox:1.36`;
- `run.sh` ends up in the image as `/app/run.sh`, executable;
- the image's ENTRYPOINT runs `/app/run.sh`, in exec form (a JSON array).
Build it on this host as `localhost/%{img}:1.0`, give the same image the second tag
`localhost/%{img}:stable`, and save what a container of it prints to `{WS}/q77/out.txt`.
""",
"setup": r"""
d="$WS/q77/image"; rm -rf "$WS/q77"; mkdir -p "$d"
podman rmi -f localhost/%{img}:1.0 localhost/%{img}:stable >/dev/null 2>&1; true
cat > "$d/run.sh" <<'EOF'
#!/bin/sh
echo "%{img} says %{word}"
EOF
cat > "$d/Dockerfile" <<'EOF'
FROM docker.io/library/busybox:1.99-nope
WORKDIR /app
COPY app.sh /app/run.sh
RUN chmod +x /app/start.sh
ENTRYPOINT /app/run.sh
EOF
sha256sum "$d/run.sh" | cut -d' ' -f1 > "$PRIV/q77.sha"
""",
"checks": [
C("localhost/%{img}:1.0 and :stable exist and are the same image", 3, IMG + r"""
a=$(img localhost/%{img}:1.0 '{{.Id}}'); b=$(img localhost/%{img}:stable '{{.Id}}'); [ -n "$a" ] && [ "$a" = "$b" ]"""),
C("a container of it prints the script's line", 3, r"""
podman run --rm localhost/%{img}:1.0 2>/dev/null | tr -d '\r' | grep -qx '%{img} says %{word}'"""),
C("built from busybox:1.36, with an exec-form ENTRYPOINT, run.sh untouched", 2, IMG + r"""
eq "$(img localhost/%{img}:1.0 '{{json .Config.Entrypoint}}')" '["/app/run.sh"]' || exit 1
grep -Eiq '^FROM +(docker\.io/)?(library/)?busybox:1\.36( |$)' "$WS/q77/image/Dockerfile" \
&& eq "$(sha256sum "$WS/q77/image/run.sh" | cut -d' ' -f1)" "$(cat "$PRIV/q77.sha")" """),
C("out.txt holds the output", 2, r"""grep -qx '%{img} says %{word}' "$WS/q77/out.txt" 2>/dev/null"""),
],
"solution": """
cd {WS}/q77/image
podman build -t localhost/%{img}:1.0 . # read the error, fix, build again - three times:
# 1. the base tag does not exist -> FROM busybox:1.36
# 2. COPY names a file that is not there -> COPY run.sh /app/run.sh
# 3. chmod names a file that is not there -> RUN chmod +x /app/run.sh
cat > Dockerfile <<EOF
FROM busybox:1.36
WORKDIR /app
COPY run.sh /app/run.sh
RUN chmod +x /app/run.sh
ENTRYPOINT ["/app/run.sh"]
EOF
podman build -t localhost/%{img}:1.0 .
podman tag localhost/%{img}:1.0 localhost/%{img}:stable
podman run --rm localhost/%{img}:1.0 | tee {WS}/q77/out.txt
podman images | grep %{img} # two tags, one IMAGE ID
# A build stops at the first step that fails: fix what the message names and build again. The shell
# form `ENTRYPOINT /app/run.sh` wraps the program in `sh -c`, so it is not PID 1, gets no signals,
# and arguments given to the container are ignored - the exec form has none of that.
""",
},
{
"id": 78, "domain": "DESIGN", "title": "What is inside this image?",
"params": lambda r: {"img": name_pick(r, "gateway"), "tag": "%d.%d" % (r.randint(1, 4), r.randint(0, 9)), "uid": r.choice([1001, 2000, 10001, 65532]),
"port": r.choice([8080, 8443, 9090, 3000]), "tier": r.choice(["edge", "backend", "batch", "internal"])},
"task": """
Image `localhost/%{img}:%{tag}` is in this host's image store (`podman images`). Its Dockerfile
is lost, and nobody remembers how it was configured. Without rebuilding it:
1. Write four lines to `{WS}/q78/facts.txt`:
user=<the user the container runs as>
workdir=<its working directory>
port=<the port it exposes, the number only>
tier=<the value of its label "tier">
2. Run a container from the image with the environment variable `MODE` set to `debug` and with
the argument `check` in place of its default argument. Save its output to `{WS}/q78/run.txt`.
""",
"setup": r"""
d="$WS/q78"; rm -rf "$d"; mkdir -p "$d/src"
podman rmi -f localhost/%{img}:%{tag} >/dev/null 2>&1; true
cat > "$d/src/start.sh" <<'EOF'
echo "mode=$MODE port=$PORT user=$(id -u) action=$1"
EOF
cat > "$d/src/Dockerfile" <<'EOF'
FROM docker.io/library/busybox:1.36
LABEL maintainer="platform@example.com" tier="%{tier}"
ENV MODE=production PORT=%{port}
WORKDIR /srv/%{img}
EXPOSE %{port}
COPY start.sh /usr/local/bin/start.sh
USER %{uid}
ENTRYPOINT ["/bin/sh", "/usr/local/bin/start.sh"]
CMD ["serve"]
EOF
podman build -q -t localhost/%{img}:%{tag} "$d/src" >/dev/null 2>&1 || { echo "the image could not be built on the host" >&2; exit 1; }
rm -rf "$d/src"
podman image inspect localhost/%{img}:%{tag} --format '{{.Id}}' | tr -d '\r' > "$PRIV/q78.id"; [ -s "$PRIV/q78.id" ]
""",
"checks": [
C("facts.txt: user and workdir", 3, r"""
grep -qx 'user=%{uid}' <(tr -d ' \r' < "$WS/q78/facts.txt" 2>/dev/null) && grep -qx 'workdir=/srv/%{img}/\?' <(tr -d ' \r' < "$WS/q78/facts.txt")"""),
C("facts.txt: port and label", 2, r"""
grep -qx 'port=%{port}\(/tcp\)\?' <(tr -d ' \r' < "$WS/q78/facts.txt" 2>/dev/null) && grep -qx 'tier=%{tier}' <(tr -d ' \r' < "$WS/q78/facts.txt")"""),
C("run.txt is the output of a container with MODE=debug and the argument check", 4, r"""
grep -qx 'mode=debug port=%{port} user=%{uid} action=check' <(tr -d '\r' < "$WS/q78/run.txt" 2>/dev/null)"""),
C("the image is still the one that was there", 1, IMG + r"""
[ -s "$WS/q78/run.txt" ] && eq "$(img localhost/%{img}:%{tag} '{{.Id}}')" "$(cat "$PRIV/q78.id")" """),
],
"solution": """
podman image inspect localhost/%{img}:%{tag} --format \\
'user={{.Config.User}} workdir={{.Config.WorkingDir}} ports={{.Config.ExposedPorts}} tier={{index .Config.Labels "tier"}}'
# user=%{uid} workdir=/srv/%{img} ports=map[%{port}/tcp:{}] tier=%{tier} -> the four lines of {WS}/q78/facts.txt
podman image inspect localhost/%{img}:%{tag} --format '{{.Config.Entrypoint}} {{.Config.Cmd}} {{.Config.Env}}'
# [/bin/sh /usr/local/bin/start.sh] [serve] [... MODE=production PORT=%{port}]
podman run --rm -e MODE=debug localhost/%{img}:%{tag} check | tee {WS}/q78/run.txt
# Everything a Dockerfile declares is in the image's configuration: `inspect` (or `podman history`)
# reads it back. Arguments after the image name replace CMD and are appended to ENTRYPOINT; -e sets
# or overrides a variable. In a Pod the same two knobs are `args` and `env`.
""",
},
# ====================================================================== 1.2 Workloads ====
{
"id": 79, "domain": "DESIGN", "title": "A StatefulSet with storage per replica",
"params": lambda r: {"ns": ns_pick(r, "data"), "app": name_pick(r, "ledger")},
"task": """
In Namespace `%{ns}`, run a small replicated store whose replicas each keep their own data and
their own name:
1. A **headless** Service `%{app}` for Pods labelled `app=%{app}`, port 80.
2. StatefulSet `%{app}`: 2 replicas, governed by that Service, image `busybox:1.36`, command
sh -c 'hostname >> /data/started; exec httpd -f -p 80 -h /data'
with a volume claim template `data` — `50Mi`, `ReadWriteOnce`, StorageClass `standard` —
mounted at `/data`.
3. When both Pods run, delete Pod `%{app}-0` once. Its replacement must come back with the same
name and the same volume: `/data/started` in it then has two lines.
""",
"setup": NS + r"""mkns %{ns}""",
"checks": [
C("Service %{app} is headless and governs the StatefulSet", 2, r"""
eq "$(j -n %{ns} svc %{app} '{.spec.clusterIP}')" None && eq "$(j -n %{ns} sts %{app} '{.spec.serviceName}')" %{app}"""),
C("2 replicas are ready, each with its own bound claim from the template", 3, r"""
eq "$(j -n %{ns} sts %{app} '{.status.readyReplicas}')" 2 && eq "$(j -n %{ns} sts %{app} '{.spec.volumeClaimTemplates[0].metadata.name}')" data || exit 1
for i in 0 1; do eq "$(j -n %{ns} pvc data-%{app}-$i '{.status.phase}')" Bound && eq "$(j -n %{ns} pvc data-%{app}-$i '{.spec.resources.requests.storage}')" 50Mi || exit 1; done"""),
C("each replica has a DNS name of its own", 2, r"""
eq "$(j -n %{ns} sts %{app} '{.status.readyReplicas}')" 2 || exit 1
$K -n %{ns} exec %{app}-1 -- nslookup %{app}-0.%{app}.%{ns}.svc.cluster.local >/dev/null 2>&1 \
&& $K -n %{ns} exec %{app}-0 -- wget -T 3 -qO- http://%{app}-1.%{app}/started 2>/dev/null | grep -q '%{app}-1'"""),
C("%{app}-0 came back with its volume: two starts on record, one for %{app}-1", 3, r"""
[ "$($K -n %{ns} exec %{app}-0 -- sh -c 'grep -c %{app}-0 /data/started' 2>/dev/null)" -ge 2 ] \
&& eq "$($K -n %{ns} exec %{app}-1 -- sh -c 'grep -c . /data/started' 2>/dev/null)" 1"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Service
metadata: {name: %{app}, namespace: %{ns}}
spec:
clusterIP: None
selector: {app: %{app}}
ports: [{port: 80}]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: %{app}, namespace: %{ns}}
spec:
serviceName: %{app}
replicas: 2
selector: {matchLabels: {app: %{app}}}
template:
metadata: {labels: {app: %{app}}}
spec:
containers:
- name: store
image: busybox:1.36
command: ["sh", "-c", "hostname >> /data/started; exec httpd -f -p 80 -h /data"]
volumeMounts: [{name: data, mountPath: /data}]
volumeClaimTemplates:
- metadata: {name: data}
spec:
accessModes: [ReadWriteOnce]
storageClassName: standard
resources: {requests: {storage: 50Mi}}
EOF
k -n %{ns} rollout status sts %{app}
k -n %{ns} delete pod %{app}-0 && k -n %{ns} rollout status sts %{app}
k -n %{ns} exec %{app}-0 -- cat /data/started # two lines
# There is no generator for a StatefulSet: write it from a Deployment's YAML (kind, serviceName,
# volumeClaimTemplates; no strategy). The claims are named <template>-<pod>, are not deleted with
# the Pod, and are found again by the replacement because it has the same name.
# <pod>.<service>.<namespace>.svc is the name the headless Service gives each replica.
""",
},
{
"id": 80, "domain": "DESIGN", "title": "A Job that retries, then cleans up",
"params": lambda r: {"ns": ns_pick(r, "batch"), "job": name_pick(r, "import")},
"task": """
Create Job `%{job}` in Namespace `%{ns}`: image `busybox:1.36`, an `emptyDir` volume mounted at
`/work`, and this command, which fails the first time it runs and succeeds the second:
sh -c 'if [ -f /work/tried ]; then echo imported; else touch /work/tried; echo failing once; exit 1; fi'
The Job must
- restart the failed **container in the same Pod** rather than start a new Pod,
- give up after at most **4** retries,
- be stopped if it has not finished after **120** seconds,
- be removed automatically **10 minutes** after it has finished.
It must complete.
""",
"setup": NS + r"""mkns %{ns}""",
"checks": [
C("the Job has the restart policy, retry limit, deadline and time to live asked for", 4, PY + r"""
py -n %{ns} job %{job} '
s = o["spec"]
s["template"]["spec"].get("restartPolicy") == "OnFailure" and s.get("backoffLimit") == 4 and s.get("activeDeadlineSeconds") == 120 and s.get("ttlSecondsAfterFinished") == 600'"""),
C("it completed", 3, r"""
for i in $(seq 1 20); do [ "$(j -n %{ns} job %{job} '{.status.succeeded}')" = 1 ] && exit 0; sleep 3; done; exit 1"""),
C("in one Pod, whose container was restarted after its failure", 3, PY + r"""
py -n %{ns} pod -l job-name=%{job} '
p = o["items"]
len(p) == 1 and p[0]["status"]["containerStatuses"][0]["restartCount"] >= 1 and p[0]["status"]["phase"] == "Succeeded" and any("emptyDir" in v for v in p[0]["spec"]["volumes"])'"""),
],
"solution": """
k -n %{ns} create job %{job} --image=busybox:1.36 --dry-run=client -o yaml -- sh -c 'if [ -f /work/tried ]; then echo imported; else touch /work/tried; echo failing once; exit 1; fi' > job.yaml
vi job.yaml
# spec:
# backoffLimit: 4
# activeDeadlineSeconds: 120
# ttlSecondsAfterFinished: 600
# template:
# spec:
# restartPolicy: OnFailure
# volumes: [{name: work, emptyDir: {}}]
# containers[0].volumeMounts: [{name: work, mountPath: /work}]
k apply -f job.yaml
k -n %{ns} get job,pod # 1/1 completions, one Pod, RESTARTS 1
k -n %{ns} logs job/%{job} # imported
# OnFailure restarts the container in place, so the emptyDir (and with it /work/tried) survives;
# with Never every retry is a new Pod with an empty volume, and this command would fail forever.
# Three specs are nested in a Job: its own (backoffLimit, deadline, ttl) and the Pod template's.
""",
},
# ================================================================ 1.3 Multi-container ====
{
"id": 81, "domain": "DESIGN", "title": "An ambassador in front of a backend",
"params": lambda r: {"ns": ns_pick(r, "front"), "pod": name_pick(r, "client"), "token": "stock-" + "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(10))},
"task": """
An application expects its backend on `localhost:8080` and cannot be told otherwise. The backend
really is Service `backend` in Namespace `%{ns}-data`. Put an **ambassador** container beside the
application that listens on `localhost:8080` and forwards to the backend.
Create Pod `%{pod}` in Namespace `%{ns}` with two containers:
- `app`: image `busybox:1.36`, command
sh -c 'while true; do wget -T 2 -qO- http://localhost:8080/ > /tmp/last 2>&1; sleep 5; done'
- `ambassador`: image `nginx:1.27`, with ConfigMap `ambassador-conf` (it exists in `%{ns}`) mounted
as the directory `/etc/nginx/conf.d`.
When it works, `/tmp/last` in the `app` container holds the backend's answer.
""",
"setup": NS + r"""
mkns %{ns}; mkns %{ns}-data
$K -n %{ns}-data run backend --image=busybox:1.36 --port=80 --labels=app=backend --command -- \
sh -c 'mkdir -p /www && echo "%{token}" > /www/index.html && exec httpd -f -p 80 -h /www' >/dev/null
$K -n %{ns}-data expose pod backend --port=80 >/dev/null
$K -n %{ns} create configmap ambassador-conf --from-literal=default.conf='server {
listen 8080;
location / {
proxy_pass http://backend.%{ns}-data.svc.cluster.local;
}
}' >/dev/null
$K -n %{ns}-data wait --for=condition=Ready pod/backend --timeout=240s >/dev/null
""",
"checks": [
C("the Pod has the containers app and ambassador, the ConfigMap mounted at /etc/nginx/conf.d", 3, PY + r"""
py -n %{ns} pod %{pod} '
c = {x["name"]: x for x in o["spec"]["containers"]}
v = {x["name"] for x in o["spec"]["volumes"] if x.get("configMap", {}).get("name") == "ambassador-conf"}
set(c) == {"app", "ambassador"} and c["ambassador"]["image"] == "nginx:1.27" and c["app"]["image"] == "busybox:1.36" and any(m["name"] in v and m["mountPath"].rstrip("/") == "/etc/nginx/conf.d" for m in c["ambassador"].get("volumeMounts", []))'"""),
C("both containers run, and the app still asks localhost:8080", 3, PY + r"""
py -n %{ns} pod %{pod} '
c = {x["name"]: x for x in o["spec"]["containers"]}
"localhost:8080" in " ".join(c["app"].get("command", []) + c["app"].get("args", [])) and all(s["ready"] for s in o["status"].get("containerStatuses", [])) and len(o["status"].get("containerStatuses", [])) == 2'"""),
C("the app gets the backend's answer through the ambassador", 4, r"""
for i in $(seq 1 10); do $K -n %{ns} exec %{pod} -c app -- cat /tmp/last 2>/dev/null | grep -qx '%{token}' && exit 0; sleep 3; done; exit 1"""),
],
"solution": """
k -n %{ns} get cm ambassador-conf -o yaml # an nginx server on 8080 that proxies to the backend Service
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- name: conf
configMap: {name: ambassador-conf}
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "while true; do wget -T 2 -qO- http://localhost:8080/ > /tmp/last 2>&1; sleep 5; done"]
- name: ambassador
image: nginx:1.27
volumeMounts: [{name: conf, mountPath: /etc/nginx/conf.d}]
EOF
k -n %{ns} exec %{pod} -c app -- cat /tmp/last # the backend's answer
# The containers of a Pod share one network namespace: what one listens on is `localhost` for the
# others. An ambassador uses that to stand in for something remote; a sidecar adds a function
# (shipping logs, reloading config); an adapter translates the application's output.
# Mounting a ConfigMap as a directory replaces everything in it - here that is wanted: the
# image's own default.conf (port 80) is replaced by the one in the ConfigMap.
""",
},
{
"id": 82, "domain": "DESIGN", "title": "Init containers in the wrong order",
"params": lambda r: {"ns": ns_pick(r, "site"), "pod": name_pick(r, "landing"), "word": r.choice(WORDS)},
"task": """
Pod `%{pod}` in Namespace `%{ns}` is meant to work like this: init container `fetch` writes
`/work/index.html`, init container `verify` checks the page, and then container `web` serves the
directory `/work` on port 80. All three share the volume `work`.
The Pod never gets that far. Find what is wrong in its definition and get it running — keep the
containers' names, images and commands.
When it works, `wget -qO- http://localhost` inside the Pod prints the page.
""",
"setup": NS + r"""
mkns %{ns}
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- {name: work, emptyDir: {}}
initContainers:
- name: verify
image: busybox:1.36
command: ["sh", "-c", "grep -q '%{word}' /work/index.html"]
volumeMounts: [{name: work, mountPath: /work}]
- name: fetch
image: busybox:1.36
command: ["sh", "-c", "echo '<h1>%{word}</h1>' > /work/index.html"]
volumeMounts: [{name: work, mountPath: /work}]
containers:
- name: web
image: busybox:1.36
command: ["httpd", "-f", "-p", "80", "-h", "/work"]
EOF
""",
"checks": [
C("fetch runs before verify, and both finished", 3, PY + r"""
py -n %{ns} pod %{pod} '
[c["name"] for c in o["spec"]["initContainers"]] == ["fetch", "verify"] and all(s.get("state", {}).get("terminated", {}).get("exitCode") == 0 for s in o["status"].get("initContainerStatuses", [])) and len(o["status"].get("initContainerStatuses", [])) == 2'"""),
C("web mounts the volume work at /work; names, images and commands are kept", 3, PY + r"""
py -n %{ns} pod %{pod} '
w = o["spec"]["containers"][0]; i = {c["name"]: c for c in o["spec"]["initContainers"]}
w["name"] == "web" and w["command"] == ["httpd", "-f", "-p", "80", "-h", "/work"] and any(m["name"] == "work" and m["mountPath"].rstrip("/") == "/work" for m in w.get("volumeMounts", [])) and "index.html" in i["fetch"]["command"][-1] and "grep" in i["verify"]["command"][-1] and all(c["image"] == "busybox:1.36" for c in [w] + list(i.values()))'"""),
C("the Pod runs and serves the page", 4, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && $K -n %{ns} exec %{pod} -- wget -T 3 -qO- http://localhost 2>/dev/null | grep -q '<h1>%{word}</h1>'"""),
],
"solution": """
k -n %{ns} get pod %{pod} # Init:Error / Init:CrashLoopBackOff
k -n %{ns} logs %{pod} -c verify # grep: /work/index.html: No such file or directory
# -> verify is listed first, but init containers run strictly in the order they are written
k -n %{ns} get pod %{pod} -o yaml > pod.yaml
vi pod.yaml # 1. move fetch above verify
# # 2. web has no volumeMounts: add {name: work, mountPath: /work}
k replace --force -f pod.yaml # a Pod's containers cannot be edited in place
k -n %{ns} exec %{pod} -- wget -qO- http://localhost
# One fault hides the next: with the order fixed the Pod starts and `web` crashes, because the
# directory it serves is not mounted into it - a volume is only in the containers that mount it.
# `k logs POD -c NAME` works for init containers too, and is the first thing to read.
""",
},
# ======================================================================== 1.4 Volumes ====
{
"id": 83, "domain": "DESIGN", "title": "A claim that stays Pending",
"params": lambda r: {"ns": ns_pick(r, "archive"), "pv": name_pick(r, "vol"), "pvc": name_pick(r, "reports"), "pod": name_pick(r, "writer")},
"task": """
Pod `%{pod}` in Namespace `%{ns}` cannot start: its PersistentVolumeClaim `%{pvc}` is `Pending`,
although PersistentVolume `%{pv}` was created for it.
1. Find out why the claim does not bind to the volume.
2. Fix the **claim** — the volume stays as it is — so that it binds to `%{pv}`. (Most of a claim
cannot be changed once it exists.)
3. The Pod must be Running; it writes `/data/hello` when it starts.
""",
"setup": NS + r"""
mkns %{ns}
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolume
metadata: {name: %{pv}}
spec:
capacity: {storage: 1Gi}
accessModes: [ReadWriteOnce]
storageClassName: manual
persistentVolumeReclaimPolicy: Retain
hostPath: {path: /var/lib/ksim-volumes/%{pv}, type: DirectoryOrCreate}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec:
accessModes: [ReadWriteMany]
storageClassName: manual
resources: {requests: {storage: 2Gi}}
---
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- name: data
persistentVolumeClaim: {claimName: %{pvc}}
containers:
- name: writer
image: busybox:1.36
command: ["sh", "-c", "date > /data/hello; sleep 86400"]
volumeMounts: [{name: data, mountPath: /data}]
EOF
j pv %{pv} '{.metadata.uid}' > "$PRIV/q83.uid"; [ -s "$PRIV/q83.uid" ]
""",
"checks": [
C("PersistentVolume %{pv} is the one that was there, unchanged", 2, r"""
eq "$(j pv %{pv} '{.metadata.uid}')" "$(cat "$PRIV/q83.uid")" && eq "$(j pv %{pv} '{.spec.capacity.storage}')" 1Gi \
&& eq "$(j pv %{pv} '{.spec.accessModes}')" '["ReadWriteOnce"]' && eq "$(j pv %{pv} '{.status.phase}')" Bound"""),
C("claim %{pvc} is bound to it", 4, r"""
eq "$(j -n %{ns} pvc %{pvc} '{.status.phase}')" Bound && eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" %{pv}"""),
C("the Pod runs and has written to the volume", 4, r"""
eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" %{pv} || exit 1
for i in $(seq 1 15); do [ "$(j -n %{ns} pod %{pod} '{.status.phase}')" = Running ] && break; sleep 3; done
$K -n %{ns} exec %{pod} -- test -s /data/hello 2>/dev/null"""),
],
"solution": """
k -n %{ns} describe pvc %{pvc} | tail -4 # no persistent volumes available for this claim ...
k get pv %{pv}; k -n %{ns} get pvc %{pvc}
# volume: 1Gi, RWO, class manual claim: 2Gi, RWX, class manual
# -> the claim asks for more than the volume has, and for an access mode it does not offer
k -n %{ns} get pvc %{pvc} -o yaml > pvc.yaml
vi pvc.yaml # accessModes: [ReadWriteOnce], storage: 1Gi (or less); drop status and uid
k -n %{ns} delete pvc %{pvc} && k apply -f pvc.yaml
k -n %{ns} get pvc,pod # Bound, and the Pod starts by itself
# A claim binds to a volume only if the class matches, the volume is at least as large as the
# request, and it offers every access mode asked for. Requests and access modes of an existing
# claim cannot be edited: delete it and create it again - a Pod that was never scheduled does not
# hold it back.
""",
},
{
"id": 84, "domain": "DESIGN", "title": "One file into a directory that has others",
"params": lambda r: {"ns": ns_pick(r, "web"), "pod": name_pick(r, "edge"), "cm": name_pick(r, "status"), "word": r.choice(WORDS)},
"task": """
ConfigMap `%{cm}` in Namespace `%{ns}` has one key, `status.conf`: an nginx server block that
answers on port 8081.
Create Pod `%{pod}` in `%{ns}` (image `nginx:1.27`) that gets that key as the single file
`/etc/nginx/conf.d/status.conf`, read-only — **without hiding** the file the image already has in
that directory, `default.conf`, which makes nginx answer on port 80.
Both ports must answer inside the Pod (`curl -s localhost:8081`, `curl -s localhost:80`).
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create configmap %{cm} --from-literal=status.conf='server {
listen 8081;
location / { return 200 "ok-%{word}\n"; }
}' >/dev/null
""",
"checks": [
C("the key is mounted as the one file status.conf, with subPath, read-only", 3, PY + r"""
py -n %{ns} pod %{pod} '
v = {x["name"] for x in o["spec"]["volumes"] if x.get("configMap", {}).get("name") == "%{cm}"}
m = [x for x in o["spec"]["containers"][0].get("volumeMounts", []) if x["name"] in v]
o["spec"]["containers"][0]["image"] == "nginx:1.27" and len(m) == 1 and m[0]["mountPath"] == "/etc/nginx/conf.d/status.conf" and m[0].get("subPath") == "status.conf" and m[0].get("readOnly") is True'"""),
C("default.conf is still there beside it", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running || exit 1
$K -n %{ns} exec %{pod} -- sh -c 'test -s /etc/nginx/conf.d/default.conf && test -f /etc/nginx/conf.d/status.conf' 2>/dev/null"""),
C("nginx answers on port 8081 from the ConfigMap, and still on port 80", 4, r"""
$K -n %{ns} exec %{pod} -- curl -s -m 3 localhost:8081 2>/dev/null | grep -qx 'ok-%{word}' \
&& $K -n %{ns} exec %{pod} -- curl -s -m 3 localhost:80 2>/dev/null | grep -qi 'welcome to nginx'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- name: status
configMap: {name: %{cm}}
containers:
- name: nginx
image: nginx:1.27
volumeMounts:
- name: status
mountPath: /etc/nginx/conf.d/status.conf
subPath: status.conf
readOnly: true
EOF
k -n %{ns} exec %{pod} -- ls /etc/nginx/conf.d # default.conf status.conf
k -n %{ns} exec %{pod} -- curl -s localhost:8081 # ok-%{word}
# A volume mounted at a directory replaces whatever the image has there. subPath mounts one entry
# of the volume at one path instead, and leaves the rest of the directory alone. The price: a
# subPath mount is not updated when the ConfigMap changes - the Pod has to be re-created.
""",
},
]