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
536 lines
26 KiB
Python
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.
|
|
""",
|
|
},
|
|
]
|