kubestronaut-sim/cka/bank_storage.py
vumas3r e761d53a96 feat: show every CKA reference solution the way the exam is done
105 of the 147 CKA reference solutions had you type what nobody types
in the exam: heredoc manifests, sed, JSON patches, kubectl wait, loops,
command substitution, jsonpath into files. Each is rewritten as the
lessons are: a generator and the lines to add in vim, a manifest pasted
from the docs page it names, kubectl edit, ssh NODE and sudo -i, or a
value read off the screen and echoed. The other 42 were commands typed
as they stand already.

A solution can now be parts (sim/bank.py cmd, hand) as a lesson step's
show is. The page and ./ksim solution N render it as Markdown, each
thing to type a block of its own that a click copies, with the edit
beneath it. A solution in parts is its own audit script: load_track
runs its parts, and for each edit by hand the script beside it, so the
solution you read is the one proven. Their separate scripts in
cka/solve.py are dropped, and a task that has both is refused.

./ksim solution lint [N|DOMAIN..] reads what the solutions have you
type against TYPING_TRAPS: the CKA bank is clean. Q49's params gain
the plural stem and shortName the solution spells out (no new draws);
Q103, ported from CKAD Q23, takes a solution of its own.

Audited from a copy with renamed hosts: Q3-Q67 (52 tasks) all solved by
their parts, after two waits were added to the audit scripts (Q43's
DNS record, Q53's Pods); Q68-Q147 still running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNSZLLsGXTMBsw17kDunkQ
2026-10-27 20:56:51 -06:00

1245 lines
55 KiB
Python

"""CKA pool tasks 114-124: Storage, the domain the bank was thinnest in.
Written for the per-task hosts: each task has a cluster of its own (kind's `standard` StorageClass,
served by the local-path provisioner in Namespace local-path-storage), so a task may break the
provisioner or change the default class. Volumes are tested for real - a claim that binds, a file
that is still on the node, a Pod that reads it.
"""
from sim.bank import cmd, hand
from cka.common import C, NS, PY, STOR, ns_pick, name_pick
# what a setup records about the objects a task must leave alone
_UID = r"""
uid() { $K get "$@" -o jsonpath='{.metadata.uid}' 2>/dev/null; }
"""
QUESTIONS = [
# ------------------------------------------------------------------ 114 ----
{
"id": 114, "domain": "STOR", "title": "A StorageClass that keeps its volumes",
"params": lambda r: {"ns": ns_pick(r, "vault"), "sc": r.choice(["keep-local", "durable-local", "retain-local"]),
"pvc": name_pick(r, "ledger"), "pod": name_pick(r, "writer"), "text": f"ledger-{r.randint(1000, 9999)}"},
"task": """
1. Create StorageClass `%{sc}`: provisioner `rancher.io/local-path` (the one behind this cluster's
`standard` class), reclaim policy `Retain`, volume binding mode `WaitForFirstConsumer`. It must
not be the default class.
2. In Namespace `%{ns}` create PVC `%{pvc}` (300Mi, ReadWriteOnce, class `%{sc}`) and Pod `%{pod}`
(`busybox:1.36`) that mounts it at `/data`, writes the line `%{text}` to `/data/keep.txt` and
then sleeps.
3. Write the name of the PersistentVolume that was provisioned to `{WS}/q114/pv.txt`.
4. Delete the Pod and the claim. Write the phase the volume is in afterwards to
`{WS}/q114/phase.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q114"; mkdir -p "$WS/q114"
$K delete sc %{sc} --ignore-not-found >/dev/null 2>&1
""",
"checks": [
C("StorageClass %{sc}: local-path, Retain, WaitForFirstConsumer, not the default", 2, r"""
eq "$(j sc %{sc} '{.provisioner}/{.reclaimPolicy}/{.volumeBindingMode}')" rancher.io/local-path/Retain/WaitForFirstConsumer \
&& [ "$(j sc %{sc} '{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" != true ]"""),
C("pv.txt names the volume that was provisioned for %{pvc}, and it is kept (Retain)", 2, STOR + r"""
pv=$(one "$WS/q114/pv.txt"); [ -n "$pv" ] || exit 1
eq "$(j pv "$pv" '{.spec.storageClassName}/{.spec.claimRef.namespace}/{.spec.claimRef.name}/{.spec.persistentVolumeReclaimPolicy}')" %{sc}/%{ns}/%{pvc}/Retain"""),
C("Pod and claim are deleted, the volume is Released, and phase.txt says so", 2, STOR + r"""
pv=$(one "$WS/q114/pv.txt"); [ -n "$pv" ] || exit 1
gone -n %{ns} pod %{pod} && gone -n %{ns} pvc %{pvc} || exit 1
eq "$(j pv "$pv" '{.status.phase}')" Released && eq "$(one "$WS/q114/phase.txt")" Released"""),
C("keep.txt is still on the node, with the line %{text}", 3, STOR + r"""
pv=$(one "$WS/q114/pv.txt"); [ -n "$pv" ] || exit 1
gone -n %{ns} pvc %{pvc} || exit 1
node=$(pvnode "$pv"); path=$(pvpath "$pv"); [ -n "$node" ] && [ -n "$path" ] || exit 1
eq "$(nx "$node" "cat $path/keep.txt" </dev/null 2>/dev/null | tr -d '\r')" "%{text}" """),
],
"solution": [cmd(r"""k get sc standard -o yaml | grep provisioner""", r"""`rancher.io/local-path`."""),
hand(r"""vim sc.yaml""",
r"""The class, from the docs page *Storage Classes* - no `is-default-class` annotation, so not the default - and the claim:
```
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: %{sc}
provisioner: rancher.io/local-path
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: %{sc}
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 300Mi
```""",
run=r"""cat > sc.yaml <<'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: %{sc}
provisioner: rancher.io/local-path
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: %{sc}
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 300Mi
EOF"""),
cmd(r"""k apply -f sc.yaml
k -n %{ns} run %{pod} --image=busybox:1.36 --dry-run=client -o yaml -- sh -c "echo %{text} > /data/keep.txt; sleep 86400" > pod.yaml""", r"""The claim stays `Pending` until a Pod uses it: `WaitForFirstConsumer`."""),
hand(r"""vim pod.yaml""",
r"""Add to the container, below `name: %{pod}`:
```
volumeMounts:
- name: data
mountPath: /data
```
and to the Pod's `spec:`, right below `spec:`:
```
volumes:
- name: data
persistentVolumeClaim:
claimName: %{pvc}
```""",
run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "data", "mountPath": "/data"}]
| .spec.volumes = [{"name": "data", "persistentVolumeClaim": {"claimName": "%{pvc}"}}]' pod.yaml"""),
cmd(r"""k apply -f pod.yaml"""),
hand(r"""k -n %{ns} get pod,pvc""",
r"""Again until the Pod is `Running` and the claim `Bound`; note its `VOLUME`.""",
run=r"""kubectl -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=240s"""),
hand(r"""echo pvc-0b1c2d3e-... > {WS}/q114/pv.txt""",
r"""With the volume's name.""",
run=r"""kubectl -n %{ns} get pvc %{pvc} -o jsonpath='{.spec.volumeName}' > {WS}/q114/pv.txt"""),
cmd(r"""k -n %{ns} delete pod %{pod} --now
k -n %{ns} delete pvc %{pvc}"""),
hand(r"""k get pv
echo Released > {WS}/q114/phase.txt""",
r"""`STATUS Released`, `RECLAIM POLICY Retain`: the class's policy is copied into every volume it provisions. A Released volume binds no new claim until its `claimRef` is cleared.""",
run=r"""sleep 4
kubectl get pv $(cat {WS}/q114/pv.txt) -o jsonpath='{.status.phase}' > {WS}/q114/phase.txt""")],
},
# ------------------------------------------------------------------ 115 ----
{
"id": 115, "domain": "STOR", "title": "A volume that lives and dies with its Pod",
"params": lambda r: {"ns": ns_pick(r, "render"), "pod": name_pick(r, "encoder")},
"task": """
In Namespace `%{ns}` create Pod `%{pod}` (image `busybox:1.36`, command `sleep 86400`) with two
volumes:
- `scratch`, a **generic ephemeral volume**: 100Mi, ReadWriteOnce, StorageClass `standard`,
mounted at `/scratch`,
- `cache`, an `emptyDir` that is kept in memory and limited to 32Mi, mounted at `/cache`.
Then write
1. the name of the PersistentVolumeClaim that Kubernetes created for the Pod to
`{WS}/q115/claim.txt`,
2. the owner of that claim to `{WS}/q115/owner.txt`, as `<kind>/<name>`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q115"; mkdir -p "$WS/q115"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1
""",
"checks": [
C("Pod %{pod} runs with ephemeral volume scratch (100Mi, RWO, standard) at /scratch", 3, PY + r"""
py -n %{ns} pod %{pod} '
v = {x["name"]: x for x in o["spec"]["volumes"]}
t = (v.get("scratch", {}).get("ephemeral") or {}).get("volumeClaimTemplate", {}).get("spec", {})
m = {x["name"]: x["mountPath"] for x in o["spec"]["containers"][0].get("volumeMounts", [])}
o["status"].get("phase") == "Running" and t.get("storageClassName") == "standard" and t.get("accessModes") == ["ReadWriteOnce"] and t.get("resources", {}).get("requests", {}).get("storage") == "100Mi" and m.get("scratch") == "/scratch"'"""),
C("/scratch is a provisioned volume the Pod can write to", 2, r"""
eq "$(j -n %{ns} pvc %{pod}-scratch '{.status.phase}')" Bound || exit 1
pv=$(j -n %{ns} pvc %{pod}-scratch '{.spec.volumeName}'); eq "$(j pv "$pv" '{.spec.storageClassName}')" standard || exit 1
eq "$($K -n %{ns} exec %{pod} -- sh -c 'echo probe > /scratch/.ksim && cat /scratch/.ksim' 2>/dev/null)" probe"""),
C("cache is an emptyDir in memory, limited to 32Mi, at /cache", 2, PY + r"""
py -n %{ns} pod %{pod} '
v = {x["name"]: x for x in o["spec"]["volumes"]}
e = v.get("cache", {}).get("emptyDir") or {}
m = {x["name"]: x["mountPath"] for x in o["spec"]["containers"][0].get("volumeMounts", [])}
e.get("medium") == "Memory" and e.get("sizeLimit") == "32Mi" and m.get("cache") == "/cache"' || exit 1
$K -n %{ns} exec %{pod} -- sh -c 'mount | grep " /cache " | grep -q tmpfs' 2>/dev/null"""),
C("claim.txt and owner.txt: %{pod}-scratch, owned by the Pod", 2, STOR + r"""
eq "$(one "$WS/q115/claim.txt" | sed 's#.*/##')" %{pod}-scratch || exit 1
eq "$(j -n %{ns} pvc %{pod}-scratch '{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}')" Pod/%{pod} || exit 1
eq "$(one "$WS/q115/owner.txt" | tr A-Z a-z)" pod/%{pod}"""),
],
"solution": [cmd(r"""k -n %{ns} run %{pod} --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > pod.yaml"""),
hand(r"""vim pod.yaml""",
r"""Add to the container, below `name: %{pod}`:
```
volumeMounts:
- name: scratch
mountPath: /scratch
- name: cache
mountPath: /cache
```
and to the Pod's `spec:`, right below `spec:` (the docs page *Ephemeral Volumes* has the generic ephemeral volume):
```
volumes:
- name: scratch
ephemeral:
volumeClaimTemplate:
spec:
storageClassName: standard
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi
- name: cache
emptyDir:
medium: Memory
sizeLimit: 32Mi
```""",
run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "scratch", "mountPath": "/scratch"}, {"name": "cache", "mountPath": "/cache"}]
| .spec.volumes = [{"name": "scratch", "ephemeral": {"volumeClaimTemplate": {"spec": {"storageClassName": "standard", "accessModes": ["ReadWriteOnce"], "resources": {"requests": {"storage": "100Mi"}}}}}}, {"name": "cache", "emptyDir": {"medium": "Memory", "sizeLimit": "32Mi"}}]' pod.yaml"""),
cmd(r"""k apply -f pod.yaml"""),
hand(r"""k -n %{ns} get pod,pvc""",
r"""Again until the Pod is `Running`. The claim is `<pod>-<volume>`: `%{pod}-scratch`.""",
run=r"""kubectl -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=240s"""),
cmd(r"""echo %{pod}-scratch > {WS}/q115/claim.txt
k -n %{ns} get pvc %{pod}-scratch -o yaml | grep -A5 ownerReferences
echo Pod/%{pod} > {WS}/q115/owner.txt""", r"""The Pod owns it: delete the Pod, and the claim and its volume go too. An emptyDir in `Memory` is a tmpfs, counted as the container's memory.""")],
},
# ------------------------------------------------------------------ 116 ----
{
"id": 116, "domain": "STOR", "title": "Claims that go when the replica goes",
"params": lambda r: {"ns": ns_pick(r, "queue"), "sts": name_pick(r, "broker")},
"task": """
StatefulSet `%{sts}` in Namespace `%{ns}` runs 3 replicas, each with a claim of its own. It is
being shrunk for good, and the claims of replicas that no longer exist only take up storage.
1. Configure the StatefulSet so that the claims of replicas removed by **scaling down** are
deleted automatically, while deleting the StatefulSet itself would still keep its claims. Do
not recreate the StatefulSet.
2. Scale it down to 1 replica.
3. Write the names of the StatefulSet's claims that exist afterwards to `{WS}/q116/claims.txt`,
one per line.
""",
"setup": NS + _UID + r"""
mkns %{ns}; rm -rf "$WS/q116"; mkdir -p "$WS/q116"
$K -n %{ns} delete sts %{sts} --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} delete pvc -l app=%{sts} --wait=true >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Service
metadata: {name: %{sts}, namespace: %{ns}}
spec: {clusterIP: None, selector: {app: %{sts}}, ports: [{port: 5672}]}
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: %{sts}, namespace: %{ns}}
spec:
serviceName: %{sts}
replicas: 3
podManagementPolicy: Parallel
selector: {matchLabels: {app: %{sts}}}
template:
metadata: {labels: {app: %{sts}}}
spec:
terminationGracePeriodSeconds: 2
containers:
- name: broker
image: busybox:1.36
command: [sh, -c, "hostname > /data/id; sleep 86400"]
volumeMounts: [{name: data, mountPath: /data}]
volumeClaimTemplates:
- metadata: {name: data, labels: {app: %{sts}}}
spec:
storageClassName: standard
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 50Mi}}
EOF
$K -n %{ns} rollout status sts %{sts} --timeout=240s >/dev/null 2>&1 || { echo "StatefulSet %{sts} did not come up"; exit 1; }
uid -n %{ns} sts %{sts} > "$PRIV/q116.uid"
""",
"checks": [
C("retention policy: claims deleted when scaled down, kept when the StatefulSet is deleted", 2, r"""
eq "$(j -n %{ns} sts %{sts} '{.spec.persistentVolumeClaimRetentionPolicy.whenScaled}/{.spec.persistentVolumeClaimRetentionPolicy.whenDeleted}')" Delete/Retain"""),
C("the same StatefulSet, scaled to 1 ready replica", 2, r"""
eq "$(j -n %{ns} sts %{sts} '{.metadata.uid}')" "$(cat "$PRIV/q116.uid")" || exit 1
for i in $(seq 1 20); do eq "$(j -n %{ns} sts %{sts} '{.spec.replicas}/{.status.replicas}/{.status.readyReplicas}')" 1/1/1 && exit 0; sleep 3; done; exit 1"""),
C("the claims of replicas 1 and 2 are gone, data-%{sts}-0 is still Bound", 3, STOR + r"""
eq "$(j -n %{ns} sts %{sts} '{.spec.replicas}')" 1 || exit 1
for i in $(seq 1 20); do
gone -n %{ns} pvc data-%{sts}-1 && gone -n %{ns} pvc data-%{sts}-2 && break; sleep 3
done
gone -n %{ns} pvc data-%{sts}-1 && gone -n %{ns} pvc data-%{sts}-2 && eq "$(j -n %{ns} pvc data-%{sts}-0 '{.status.phase}')" Bound"""),
C("claims.txt lists what is left: data-%{sts}-0", 1, r"""
eq "$(tr -d ' \t\r' < "$WS/q116/claims.txt" 2>/dev/null | sed 's#.*/##' | grep . | sort | tr '\n' ' ')" "data-%{sts}-0 " """),
],
"solution": [cmd(r"""k -n %{ns} get sts %{sts} -o yaml | grep -A2 persistentVolumeClaimRetentionPolicy""", r"""`Retain` for both: by default a StatefulSet never deletes a claim."""),
hand(r"""k -n %{ns} edit sts %{sts}""",
r"""Set it before scaling - it applies to the Pods being removed - then `:wq`:
```
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Delete
```""",
run=r"""kubectl -n %{ns} patch sts %{sts} -p '{"spec":{"persistentVolumeClaimRetentionPolicy":{"whenScaled":"Delete","whenDeleted":"Retain"}}}'"""),
cmd(r"""k -n %{ns} scale sts %{sts} --replicas=1"""),
hand(r"""k -n %{ns} get pvc""",
r"""Again until `data-%{sts}-2` and `data-%{sts}-1` have gone with their Pods.""",
run=r"""for i in $(seq 1 40); do [ "$(kubectl -n %{ns} get pvc --no-headers | wc -l)" = 1 ] && break; sleep 3; done"""),
cmd(r"""echo data-%{sts}-0 > {WS}/q116/claims.txt""")],
},
# ------------------------------------------------------------------ 117 ----
{
"id": 117, "domain": "STOR", "difficulty": "hard", "title": "Reserve a volume for one claim",
"params": lambda r: {"ns": ns_pick(r, "atlas"), "oth": ns_pick(r, "pipeline"), "pv": name_pick(r, "pv-atlas"),
"pvc": name_pick(r, "maps")},
"task": """
Claim `any-volume` in Namespace `%{oth}` is Pending: it waits for any 1Gi volume of class `manual`,
and it has to keep waiting - its volume arrives next week.
Team `%{ns}` needs storage today. Create PersistentVolume `%{pv}` (1Gi, ReadWriteOnce, class
`manual`, `hostPath` `/mnt/%{pv}`, reclaim policy `Retain`) **reserved** for a claim `%{pvc}` in
Namespace `%{ns}`, then create that claim (1Gi, ReadWriteOnce, class `manual`). It must bind
`%{pv}`.
`any-volume` must not be changed, deleted or recreated, and it must not get the volume.
""",
"setup": NS + _UID + r"""
mkns %{ns}; mkns %{oth}
$K -n %{ns} delete pvc %{pvc} --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{oth} delete pvc any-volume --ignore-not-found --wait=true >/dev/null 2>&1
$K delete pv %{pv} --ignore-not-found --wait=true >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: any-volume, namespace: %{oth}}
spec:
storageClassName: manual
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 1Gi}}
EOF
uid -n %{oth} pvc any-volume > "$PRIV/q117.uid"
""",
"checks": [
C("PersistentVolume %{pv}: 1Gi, RWO, manual, hostPath /mnt/%{pv}, Retain", 2, r"""
eq "$(j pv %{pv} '{.spec.capacity.storage}/{.spec.accessModes[*]}/{.spec.storageClassName}/{.spec.persistentVolumeReclaimPolicy}')" 1Gi/ReadWriteOnce/manual/Retain \
&& eq "$(j pv %{pv} '{.spec.hostPath.path}')" /mnt/%{pv}"""),
C("it is reserved: its claimRef names %{pvc} in %{ns}", 2, r"""
eq "$(j pv %{pv} '{.spec.claimRef.namespace}/{.spec.claimRef.name}')" %{ns}/%{pvc}"""),
C("claim %{pvc} (1Gi, RWO, manual) is Bound to %{pv}", 3, STOR + r"""
bound %{ns} %{pvc} 30 || exit 1
eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}/{.spec.storageClassName}/{.spec.accessModes[*]}/{.spec.resources.requests.storage}')" %{pv}/manual/ReadWriteOnce/1Gi"""),
C("any-volume is the same claim as before and still Pending", 2, r"""
[ -n "$(j pv %{pv} '{.metadata.name}')" ] || exit 1
eq "$(j -n %{oth} pvc any-volume '{.metadata.uid}')" "$(cat "$PRIV/q117.uid")" && eq "$(j -n %{oth} pvc any-volume '{.status.phase}')" Pending"""),
],
"solution": [cmd(r"""k -n %{oth} get pvc any-volume""", r"""`Pending`, for any 1Gi `manual` volume: an Available one would go to it."""),
hand(r"""vim pv.yaml""",
r"""A `claimRef` on the PV reserves it for one claim, so the waiting one cannot take it:
```
apiVersion: v1
kind: PersistentVolume
metadata:
name: %{pv}
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /mnt/%{pv}
claimRef:
namespace: %{ns}
name: %{pvc}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
`volumeName` on a claim is the other half: it asks for one volume, but keeps no other claim away from it.""",
run=r"""cat > pv.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolume
metadata:
name: %{pv}
spec:
storageClassName: manual
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
hostPath:
path: /mnt/%{pv}
claimRef:
namespace: %{ns}
name: %{pvc}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
EOF"""),
cmd(r"""k apply -f pv.yaml
k get pv %{pv}
k -n %{ns} get pvc %{pvc}
k -n %{oth} get pvc any-volume""", r"""`Bound`, `Bound`, still `Pending`.""")],
},
# ------------------------------------------------------------------ 118 ----
{
"id": 118, "domain": "STOR", "difficulty": "hard", "workers": 1, "title": "The claim took the wrong volume",
"params": lambda r: {"ns": ns_pick(r, "books"), "dep": name_pick(r, "ledger"), "pvc": name_pick(r, "ledger-data"),
"pv": name_pick(r, "pv-ledger"), "tok": f"{r.randint(100000, 999999)}"},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` is meant to read its ledger from PersistentVolume `%{pv}`,
which an administrator prepared by hand - but its log says `no ledger found`. The manifest it was
created from is `{WS}/q118/app.yaml`.
1. Write the name of the volume that claim `%{pvc}` got instead to `{WS}/q118/wrong-pv.txt`.
2. Make claim `%{pvc}` - same name, same size - bind `%{pv}`, so that the Deployment's Pod prints
the ledger. The volume it got by mistake must be gone in the end; `%{pv}` itself and the files
on it must not be changed.
""",
"setup": NS + _UID + r"""
mkns %{ns}; rm -rf "$WS/q118"; mkdir -p "$WS/q118"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} delete pvc %{pvc} --ignore-not-found --wait=true >/dev/null 2>&1
$K delete pv %{pv} --ignore-not-found --wait=true >/dev/null 2>&1
nx "$W1" "mkdir -p /mnt/%{pv} && echo 'ledger %{tok} balanced' > /mnt/%{pv}/ledger.db" </dev/null || exit 1
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolume
metadata: {name: %{pv}}
spec:
capacity: {storage: 500Mi}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
hostPath: {path: /mnt/%{pv}}
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions: [{key: kubernetes.io/hostname, operator: In, values: ["$W1"]}]
EOF
cat > "$WS/q118/app.yaml" <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec:
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 500Mi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
terminationGracePeriodSeconds: 2
volumes: [{name: data, persistentVolumeClaim: {claimName: %{pvc}}}]
containers:
- name: app
image: busybox:1.36
command: [sh, -c, "cat /data/ledger.db 2>/dev/null || echo 'no ledger found'; sleep 86400"]
volumeMounts: [{name: data, mountPath: /data}]
EOF
$K apply -f "$WS/q118/app.yaml" >/dev/null
$K -n %{ns} rollout status deploy %{dep} --timeout=240s >/dev/null 2>&1 || { echo "Deployment %{dep} did not come up"; exit 1; }
$K -n %{ns} get pvc %{pvc} -o jsonpath='{.spec.volumeName}' > "$PRIV/q118.wrong"
[ -s "$PRIV/q118.wrong" ] || { echo "claim %{pvc} was not provisioned"; exit 1; }
uid pv %{pv} > "$PRIV/q118.uid"
""",
"checks": [
C("wrong-pv.txt names the volume the claim was given by mistake", 1, STOR + r"""
eq "$(one "$WS/q118/wrong-pv.txt" | sed 's#.*/##')" "$(cat "$PRIV/q118.wrong")" """),
C("claim %{pvc} (500Mi) is Bound to %{pv}, which is the volume it always was", 3, STOR + r"""
bound %{ns} %{pvc} 30 || exit 1
eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}/{.spec.resources.requests.storage}')" %{pv}/500Mi || exit 1
eq "$(j pv %{pv} '{.metadata.uid}/{.spec.hostPath.path}')" "$(cat "$PRIV/q118.uid")//mnt/%{pv}" """),
C("the Deployment's Pod prints the ledger", 3, r"""
eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" %{pv} || exit 1
for i in $(seq 1 20); do
$K -n %{ns} logs deploy/%{dep} 2>/dev/null | grep -q 'ledger %{tok} balanced' && exit 0; sleep 3
done; exit 1"""),
C("the volume provisioned by mistake is gone", 1, STOR + r"""
eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" %{pv} || exit 1
for i in $(seq 1 10); do gone pv "$(cat "$PRIV/q118.wrong")" && exit 0; sleep 3; done; exit 1"""),
],
"solution": [cmd(r"""k -n %{ns} get pvc %{pvc}
k get pv %{pv}""", r"""Bound to a `pvc-...` volume of class `standard`, while `%{pv}` is `Available` with no class: the manifest names no class, so the claim got the default one, and a new empty volume."""),
hand(r"""echo pvc-0b1c2d3e-... > {WS}/q118/wrong-pv.txt""",
r"""With the claim's `VOLUME`.""",
run=r"""kubectl -n %{ns} get pvc %{pvc} -o jsonpath='{.spec.volumeName}' > {WS}/q118/wrong-pv.txt"""),
cmd(r"""k -n %{ns} scale deploy %{dep} --replicas=0""", r"""A claim's class and volume cannot be edited, and a claim in use is not deleted (pvc-protection): stop the Pod first."""),
hand(r"""k -n %{ns} get pods""",
r"""Again until the Pod has gone.""",
run=r"""kubectl -n %{ns} wait --for=delete pod -l app=%{dep} --timeout=120s"""),
cmd(r"""k -n %{ns} delete pvc %{pvc}""", r"""Its volume goes with it: reclaim policy `Delete`."""),
hand(r"""vim pvc.yaml""",
r"""`storageClassName: ""` - no class: never the default, never provisioned - and the volume by name:
```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: ""
volumeName: %{pv}
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Mi
```
`volumeName` alone would not do: the default class is still filled in, and it does not match the class-less volume.""",
run=r"""cat > pvc.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: ""
volumeName: %{pv}
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 500Mi
EOF"""),
cmd(r"""k apply -f pvc.yaml
k -n %{ns} scale deploy %{dep} --replicas=1
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} logs deploy/%{dep}""", r"""`ledger %{tok} balanced`.""")],
},
# ------------------------------------------------------------------ 119 ----
{
"id": 119, "domain": "STOR", "difficulty": "hard", "title": "Three claims, three reasons",
"params": lambda r: {"ns": ns_pick(r, "studio"), "p": name_pick(r, "pv")},
"task": """
Three claims in Namespace `%{ns}` are Pending: `reports`, `archive` and `media`. Each is meant to
bind the PersistentVolume that carries its `use` label - `%{p}-reports`, `%{p}-archive`,
`%{p}-media` - and each fails for a different reason.
1. Write the reason for each to `{WS}/q119/reasons.txt`, one line per claim as
`<claim> <reason>`, where the reason is one of `capacity`, `accessmode`, `class`.
2. Get all three Bound to their volumes. The PersistentVolumes must not be changed or recreated;
a claim may be recreated under its name, keeping its selector.
""",
"setup": NS + _UID + r"""
mkns %{ns}; rm -rf "$WS/q119"; mkdir -p "$WS/q119"
$K -n %{ns} delete pvc reports archive media --ignore-not-found --wait=true >/dev/null 2>&1
$K delete pv %{p}-reports %{p}-archive %{p}-media --ignore-not-found --wait=true >/dev/null 2>&1
pv() { # use size
cat <<EOF
---
apiVersion: v1
kind: PersistentVolume
metadata: {name: %{p}-$1, labels: {use: $1}}
spec:
capacity: {storage: $2}
accessModes: [ReadWriteOnce]
storageClassName: manual
persistentVolumeReclaimPolicy: Retain
hostPath: {path: /mnt/%{p}-$1}
EOF
}
pvc() { # name size mode class
cat <<EOF
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: $1, namespace: %{ns}}
spec:
storageClassName: $4
accessModes: [$3]
selector: {matchLabels: {use: $1}}
resources: {requests: {storage: $2}}
EOF
}
{ pv reports 1Gi; pv archive 2Gi; pv media 1Gi
pvc reports 5Gi ReadWriteOnce manual; pvc archive 2Gi ReadWriteMany manual; pvc media 1Gi ReadWriteOnce manuel
} | $K apply -f - >/dev/null
for u in reports archive media; do uid pv %{p}-$u; echo; done > "$PRIV/q119.uids"
""",
"checks": [
C("reasons.txt: reports capacity, archive accessmode, media class", 2, r"""
eq "$(tr -s ' \t' ' ' < "$WS/q119/reasons.txt" 2>/dev/null | tr -d '\r' | sed 's/^ //; s/ $//' | tr A-Z a-z | grep . | sort | tr '\n' ';')" \
"archive accessmode;media class;reports capacity;" """),
] + [
C(f"claim {u} is Bound to %{{p}}-{u}, and the volume is as it was", 2, STOR + rf"""
bound %{{ns}} {u} 30 || exit 1
eq "$(j -n %{{ns}} pvc {u} '{{.spec.volumeName}}/{{.spec.selector.matchLabels.use}}')" %{{p}}-{u}/{u} || exit 1
eq "$(j pv %{{p}}-{u} '{{.metadata.uid}}')" "$(sed -n {n}p "$PRIV/q119.uids")" || exit 1
eq "$(j pv %{{p}}-{u} '{{.spec.capacity.storage}}/{{.spec.accessModes[*]}}/{{.spec.storageClassName}}/{{.metadata.labels.use}}')" {size}/ReadWriteOnce/manual/{u}""")
for n, (u, size) in enumerate([("reports", "1Gi"), ("archive", "2Gi"), ("media", "1Gi")], 1)
],
"solution": [cmd(r"""k -n %{ns} get pvc
k -n %{ns} describe pvc reports archive media | grep -e ^Name: -e Warning -e Normal""", r"""`media`: `storageclass.storage.k8s.io "manuel" not found` - the class, a typo. The other two only say no volume fits: compare claim and volume yourself."""),
cmd(r"""k -n %{ns} get pvc -o custom-columns=NAME:.metadata.name,SIZE:.spec.resources.requests.storage,MODES:.spec.accessModes,CLASS:.spec.storageClassName
k get pv -o custom-columns=NAME:.metadata.name,SIZE:.spec.capacity.storage,MODES:.spec.accessModes,CLASS:.spec.storageClassName""", r"""`reports` asks for 5Gi of a 1Gi volume; `archive` for `ReadWriteMany`, its volume offers `ReadWriteOnce`. A claim binds only when the class matches, the volume is large enough, it has every access mode asked for, and the selector matches."""),
hand(r"""vim {WS}/q119/reasons.txt""",
r"""```
reports capacity
archive accessmode
media class
```""",
run=r"""printf 'reports capacity\narchive accessmode\nmedia class\n' > {WS}/q119/reasons.txt"""),
cmd(r"""k -n %{ns} delete pvc reports archive media""", r"""None of the three fields can be edited on a claim: recreate them."""),
hand(r"""vim pvcs.yaml""",
r"""The three claims again, each keeping its selector:
```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reports
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
selector:
matchLabels:
use: reports
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: archive
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
selector:
matchLabels:
use: archive
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: media
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
selector:
matchLabels:
use: media
resources:
requests:
storage: 1Gi
```""",
run=r"""cat > pvcs.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reports
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
selector:
matchLabels:
use: reports
resources:
requests:
storage: 1Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: archive
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
selector:
matchLabels:
use: archive
resources:
requests:
storage: 2Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: media
namespace: %{ns}
spec:
storageClassName: manual
accessModes:
- ReadWriteOnce
selector:
matchLabels:
use: media
resources:
requests:
storage: 1Gi
EOF"""),
cmd(r"""k apply -f pvcs.yaml
k -n %{ns} get pvc""", r"""All three `Bound`, each to its own volume.""")],
},
# ------------------------------------------------------------------ 120 ----
{
"id": 120, "domain": "STOR", "difficulty": "hard", "workers": 2, "title": "Volume on one node, Pod sent to another",
"params": lambda r: {"ns": ns_pick(r, "shop"), "dep": name_pick(r, "orders"), "pv": name_pick(r, "pv-orders"),
"tok": f"{r.randint(100000, 999999)}"},
"task": """
The Pod of Deployment `%{dep}` in Namespace `%{ns}` is Pending.
1. Write the scheduler's message from the Pod's events to `{WS}/q120/event.txt`.
2. Get the Pod running so that it prints its data file: its log must show the `orders` line that
is stored on the volume. That data exists on one node only - do not change, recreate or copy
the PersistentVolume, its claim or the files.
""",
"setup": NS + _UID + r"""
mkns %{ns}; rm -rf "$WS/q120"; mkdir -p "$WS/q120"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} delete pvc %{dep}-data --ignore-not-found --wait=true >/dev/null 2>&1
$K delete pv %{pv} --ignore-not-found --wait=true >/dev/null 2>&1
nx "$W1" "mkdir -p /mnt/%{pv} && echo 'orders %{tok} open' > /mnt/%{pv}/orders.db" </dev/null || exit 1
$K label node "$W1" disk- >/dev/null 2>&1; $K label node "$W2" disk=ssd --overwrite >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolume
metadata: {name: %{pv}}
spec:
capacity: {storage: 1Gi}
accessModes: [ReadWriteOnce]
storageClassName: manual
persistentVolumeReclaimPolicy: Retain
local: {path: /mnt/%{pv}}
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions: [{key: kubernetes.io/hostname, operator: In, values: ["$W1"]}]
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{dep}-data, namespace: %{ns}}
spec:
storageClassName: manual
volumeName: %{pv}
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 1Gi}}
EOF
# the claim first, so that the Pod's only scheduling problem is the one the task is about
for i in $(seq 1 30); do [ "$(j -n %{ns} pvc %{dep}-data '{.status.phase}')" = Bound ] && break; sleep 2; done
[ "$(j -n %{ns} pvc %{dep}-data '{.status.phase}')" = Bound ] || { echo "claim %{dep}-data did not bind"; exit 1; }
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
terminationGracePeriodSeconds: 2
nodeSelector: {disk: ssd}
volumes: [{name: data, persistentVolumeClaim: {claimName: %{dep}-data}}]
containers:
- name: app
image: busybox:1.36
command: [sh, -c, "cat /data/orders.db; sleep 86400"]
volumeMounts: [{name: data, mountPath: /data}]
EOF
{ uid pv %{pv}; echo; uid -n %{ns} pvc %{dep}-data; echo; } > "$PRIV/q120.uids"
""",
"checks": [
C("event.txt holds the scheduler's message: no node matches the volume's node affinity", 2, r"""
grep -Eqi "match PersistentVolume's node affinity|volume node affinity conflict" "$WS/q120/event.txt" 2>/dev/null"""),
C("the Deployment's Pod runs on the node that holds the volume", 3, STOR + r"""
for i in $(seq 1 20); do p=$(newest %{ns} app=%{dep}); [ -n "$p" ] && break; sleep 3; done
[ -n "$p" ] && eq "$(j -n %{ns} pod "$p" '{.spec.nodeName}')" "$W1" """),
C("its log shows the orders line from the volume", 2, r"""
for i in $(seq 1 10); do
$K -n %{ns} logs deploy/%{dep} 2>/dev/null | grep -q 'orders %{tok} open' && exit 0; sleep 3
done; exit 1"""),
C("PersistentVolume and claim are the ones that were there", 1, STOR + r"""
[ -n "$(newest %{ns} app=%{dep})" ] || exit 1
eq "$(j pv %{pv} '{.metadata.uid}')" "$(sed -n 1p "$PRIV/q120.uids")" && eq "$(j -n %{ns} pvc %{dep}-data '{.metadata.uid}')" "$(sed -n 2p "$PRIV/q120.uids")" \
&& eq "$(pvnode %{pv})/$(pvpath %{pv})" "$W1//mnt/%{pv}" """),
],
"solution": [hand(r"""k -n %{ns} describe pod -l app=%{dep} | grep FailedScheduling > {WS}/q120/event.txt
cat {WS}/q120/event.txt""",
r"""`1 node(s) didn't match PersistentVolume's node affinity, 1 node(s) didn't match Pod's node affinity/selector`: the scheduler ANDs every constraint - taints, the Pod's selector, and the node affinity of each volume it mounts.""",
run=r"""for i in $(seq 1 40); do
kubectl -n %{ns} describe pod -l app=%{dep} | grep FailedScheduling > {WS}/q120/event.txt && break; sleep 3
done"""),
cmd(r"""k describe pv %{pv} | grep -A3 'Node Affinity'
k -n %{ns} get deploy %{dep} -o yaml | grep -A1 nodeSelector
k get nodes -L disk""", r"""The volume is on `{W1}`; the Pod must go to `disk=ssd`, which only `{W2}` has. The volume cannot move, so the Pod has to."""),
hand(r"""k -n %{ns} edit deploy %{dep}""",
r"""Delete the two lines of the `nodeSelector` (`nodeSelector:` and `disk: ssd`), then `:wq` - the volume's node affinity already sends the Pod to the right node. (If the selector has a reason to exist, `k label node {W1} disk=ssd` instead.)""",
run=r"""kubectl -n %{ns} patch deploy %{dep} --type=json -p '[{"op":"remove","path":"/spec/template/spec/nodeSelector"}]'"""),
cmd(r"""k -n %{ns} rollout status deploy %{dep}
k -n %{ns} logs deploy/%{dep}""", r"""`orders %{tok} open`.""")],
},
# ------------------------------------------------------------------ 121 ----
{
"id": 121, "domain": "STOR", "title": "A claim that will not go away",
"params": lambda r: {"ns": ns_pick(r, "gallery"), "dep": name_pick(r, "thumbs"), "pvc": name_pick(r, "uploads")},
"task": """
Claim `%{pvc}` in Namespace `%{ns}` was deleted yesterday, and it is still there, `Terminating`.
1. Write the finalizer that keeps it to `{WS}/q121/finalizer.txt`.
2. Write what still uses the claim to `{WS}/q121/used-by.txt`, as `<kind>/<name>` of the workload
(not of a Pod).
3. The data is not needed any more. Let the deletion finish - do not edit the claim - by making
that workload keep its files in an `emptyDir` at the same path instead. It must keep its name
and end up with 1 running replica.
""",
"setup": NS + _UID + r"""
mkns %{ns}; rm -rf "$WS/q121"; mkdir -p "$WS/q121"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} delete pvc %{pvc} --ignore-not-found --wait=true >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec:
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 100Mi}}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 1
strategy: {type: Recreate}
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
terminationGracePeriodSeconds: 2
volumes: [{name: files, persistentVolumeClaim: {claimName: %{pvc}}}]
containers:
- name: worker
image: busybox:1.36
command: [sh, -c, "date > /srv/files/started; sleep 86400"]
volumeMounts: [{name: files, mountPath: /srv/files}]
EOF
$K -n %{ns} rollout status deploy %{dep} --timeout=240s >/dev/null 2>&1 || { echo "Deployment %{dep} did not come up"; exit 1; }
$K -n %{ns} get pvc %{pvc} -o jsonpath='{.spec.volumeName}' > "$PRIV/q121.pv"
uid -n %{ns} deploy %{dep} > "$PRIV/q121.uid"
$K -n %{ns} delete pvc %{pvc} --wait=false >/dev/null
""",
"checks": [
C("finalizer.txt holds kubernetes.io/pvc-protection", 1, STOR + r"""eq "$(one "$WS/q121/finalizer.txt")" kubernetes.io/pvc-protection"""),
C("used-by.txt names Deployment %{dep}", 2, STOR + r"""
case "$(one "$WS/q121/used-by.txt" | tr A-Z a-z)" in
deployment/%{dep}|deployments/%{dep}|deploy/%{dep}|deployment.apps/%{dep}|deployments.apps/%{dep}) ;; *) exit 1 ;; esac"""),
C("the claim is gone, and its volume with it", 3, STOR + r"""
for i in $(seq 1 20); do gone -n %{ns} pvc %{pvc} && gone pv "$(cat "$PRIV/q121.pv")" && exit 0; sleep 3; done; exit 1"""),
C("the same Deployment runs 1 replica with an emptyDir at /srv/files", 2, PY + r"""
eq "$(j -n %{ns} deploy %{dep} '{.metadata.uid}')" "$(cat "$PRIV/q121.uid")" || exit 1
py -n %{ns} deploy %{dep} '
s = o["spec"]["template"]["spec"]
v = {x["name"]: x for x in s.get("volumes", [])}
m = [x["name"] for x in s["containers"][0].get("volumeMounts", []) if x["mountPath"] == "/srv/files"]
len(m) == 1 and "emptyDir" in v.get(m[0], {}) and not any("persistentVolumeClaim" in x for x in v.values())' || exit 1
for i in $(seq 1 20); do eq "$(j -n %{ns} deploy %{dep} '{.status.updatedReplicas}/{.status.availableReplicas}/{.status.replicas}')" 1/1/1 && exit 0; sleep 3; done; exit 1"""),
],
"solution": """
k -n %{ns} get pvc %{pvc} # STATUS Terminating
k -n %{ns} get pvc %{pvc} -o jsonpath='{.metadata.finalizers}'; echo # ["kubernetes.io/pvc-protection"]
echo kubernetes.io/pvc-protection > {WS}/q121/finalizer.txt
k -n %{ns} describe pvc %{pvc} | grep 'Used By' # Used By: %{dep}-... (a Pod; find its owner)
k -n %{ns} get deploy -o custom-columns=NAME:.metadata.name,CLAIMS:.spec.template.spec.volumes[*].persistentVolumeClaim.claimName
echo deployment/%{dep} > {WS}/q121/used-by.txt
k -n %{ns} edit deploy %{dep}
# volumes:
# - name: files
# emptyDir: {} <- instead of persistentVolumeClaim: {claimName: %{pvc}}
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} get pvc; k get pv # the claim is gone, and so is its volume (reclaim policy Delete)
# "Storage object in use protection": a claim that a Pod still mounts is only marked for deletion;
# the pvc-protection finalizer is removed by the control plane when the last such Pod is gone.
# Removing the finalizer by hand would pull the claim out from under a running Pod - the fix is
# always to stop or change whatever uses it.
""",
},
# ------------------------------------------------------------------ 122 ----
{
"id": 122, "domain": "STOR", "difficulty": "hard", "title": "Dynamic provisioning has stopped",
"params": lambda r: {"ns": ns_pick(r, "lab"), "sc": r.choice(["node-local", "scratch-local", "team-local"]),
"pvc": name_pick(r, "results"), "pod": name_pick(r, "runner")},
"task": """
Pod `%{pod}` in Namespace `%{ns}` is Pending because its claim `%{pvc}` never gets a volume. Until
last week, volumes of StorageClass `%{sc}` were provisioned within seconds. More than one thing is
wrong.
1. Fix it: the claim must be Bound to a dynamically provisioned volume of class `%{sc}`, and the
Pod must run. The class keeps its name, its reclaim policy and its volume binding mode. Claim
and Pod may be recreated from `{WS}/q122/app.yaml` if you need to.
2. Write the provisioner that class `%{sc}` uses in the end to `{WS}/q122/provisioner.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q122"; mkdir -p "$WS/q122"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1
$K -n %{ns} delete pvc %{pvc} --ignore-not-found --wait=true >/dev/null 2>&1
$K delete sc %{sc} --ignore-not-found >/dev/null 2>&1
$K -n local-path-storage get deploy local-path-provisioner >/dev/null 2>&1 || { echo "this cluster has no local-path provisioner"; exit 1; }
$K apply -f - >/dev/null <<'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: {name: %{sc}}
provisioner: rancher.io/localpath
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
EOF
$K -n local-path-storage scale deploy local-path-provisioner --replicas=0 >/dev/null
cat > "$WS/q122/app.yaml" <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec:
storageClassName: %{sc}
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 200Mi}}
---
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
terminationGracePeriodSeconds: 2
volumes: [{name: out, persistentVolumeClaim: {claimName: %{pvc}}}]
containers:
- name: runner
image: busybox:1.36
command: [sh, -c, "date > /out/started; sleep 86400"]
volumeMounts: [{name: out, mountPath: /out}]
EOF
$K apply -f "$WS/q122/app.yaml" >/dev/null
""",
"checks": [
C("the provisioner is running again", 2, r"""
[ "$(j -n local-path-storage deploy local-path-provisioner '{.status.availableReplicas}')" -ge 1 ] 2>/dev/null"""),
C("StorageClass %{sc}: provisioner rancher.io/local-path, still Delete and WaitForFirstConsumer", 2, r"""
eq "$(j sc %{sc} '{.provisioner}/{.reclaimPolicy}/{.volumeBindingMode}')" rancher.io/local-path/Delete/WaitForFirstConsumer"""),
C("claim %{pvc} is Bound to a volume of class %{sc}, and Pod %{pod} runs", 3, STOR + r"""
bound %{ns} %{pvc} 60 && running %{ns} %{pod} 60 || exit 1
pv=$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')
eq "$(j pv "$pv" '{.spec.storageClassName}')" %{sc} && eq "$(j -n %{ns} pod %{pod} '{.spec.volumes[0].persistentVolumeClaim.claimName}')" %{pvc}"""),
C("a new claim of class %{sc} gets a volume within a minute", 2, STOR + r"""
$K -n %{ns} delete pod ksim-probe --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1
$K -n %{ns} delete pvc ksim-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K apply -f - >/dev/null 2>&1 <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: ksim-probe, namespace: %{ns}}
spec:
storageClassName: %{sc}
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 10Mi}}
---
apiVersion: v1
kind: Pod
metadata: {name: ksim-probe, namespace: %{ns}}
spec:
terminationGracePeriodSeconds: 1
volumes: [{name: v, persistentVolumeClaim: {claimName: ksim-probe}}]
containers: [{name: c, image: "busybox:1.36", command: [sleep, "600"], volumeMounts: [{name: v, mountPath: /v}]}]
EOF
rc=1; bound %{ns} ksim-probe 60 && rc=0
$K -n %{ns} delete pod ksim-probe --wait=false --grace-period=1 >/dev/null 2>&1
$K -n %{ns} delete pvc ksim-probe --wait=false >/dev/null 2>&1
exit $rc"""),
C("provisioner.txt holds rancher.io/local-path", 1, STOR + r"""
eq "$(j sc %{sc} '{.provisioner}')" rancher.io/local-path && eq "$(one "$WS/q122/provisioner.txt")" rancher.io/local-path"""),
],
"solution": """
k -n %{ns} describe pvc %{pvc} | tail -3
# waiting for a volume to be created either by the external provisioner 'rancher.io/localpath'
# or manually by the system administrator ... verify that the provisioner is running and correctly registered
k get sc # %{sc}: rancher.io/localpath standard: rancher.io/local-path <- a typo
k get pods -A | grep -i provision # nothing: who serves rancher.io/local-path?
k -n local-path-storage get deploy # local-path-provisioner 0/0 - scaled to zero
k -n local-path-storage scale deploy local-path-provisioner --replicas=1
# a StorageClass's provisioner cannot be edited: write it out, correct it, replace it
k get sc %{sc} -o yaml > sc.yaml
vi sc.yaml # provisioner: rancher.io/local-path (drop uid, resourceVersion, creationTimestamp)
k delete sc %{sc}; k apply -f sc.yaml
k -n %{ns} get pvc,pod -w # the waiting claim is picked up by itself within half a minute
# (if it is not: k -n %{ns} delete pod %{pod}; k -n %{ns} delete pvc %{pvc}; k apply -f {WS}/q122/app.yaml)
echo rancher.io/local-path > {WS}/q122/provisioner.txt
# Dynamic provisioning is a chain: the claim names a class, the class names a provisioner, and a
# controller that answers to exactly that name has to be running. The claim's event tells you
# which name it is waiting for - then check the class for a typo and the cluster for the controller.
""",
},
# ------------------------------------------------------------------ 123 ----
{
"id": 123, "domain": "STOR", "title": "A claim that waits for a default class",
"params": lambda r: {"ns": ns_pick(r, "intake"), "sc": name_pick(r, "general"), "pvc": name_pick(r, "inbox"),
"pod": name_pick(r, "scanner")},
"task": """
Claim `%{pvc}` in Namespace `%{ns}` is Pending, and so is Pod `%{pod}` that mounts it: the claim
names no StorageClass, and this cluster has no default one at the moment.
1. Without changing, deleting or recreating the claim or the Pod, get the claim Bound to a volume
of class `%{sc}` and the Pod running.
2. Exactly one StorageClass must be the default in the end.
3. Write the StorageClass the claim ended up with to `{WS}/q123/class.txt`.
""",
"setup": NS + _UID + r"""
mkns %{ns}; rm -rf "$WS/q123"; mkdir -p "$WS/q123"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1
$K -n %{ns} delete pvc %{pvc} --ignore-not-found --wait=true >/dev/null 2>&1
$K delete sc %{sc} --ignore-not-found >/dev/null 2>&1
$K annotate sc standard storageclass.kubernetes.io/is-default-class- >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: {name: %{sc}}
provisioner: rancher.io/local-path
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec:
accessModes: [ReadWriteOnce]
resources: {requests: {storage: 200Mi}}
---
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
terminationGracePeriodSeconds: 2
volumes: [{name: inbox, persistentVolumeClaim: {claimName: %{pvc}}}]
containers:
- name: scanner
image: busybox:1.36
command: [sleep, "86400"]
volumeMounts: [{name: inbox, mountPath: /inbox}]
EOF
[ -z "$(j -n %{ns} pvc %{pvc} '{.spec.storageClassName}')" ] || { echo "claim %{pvc} was given a class although no default exists"; exit 1; }
{ uid -n %{ns} pvc %{pvc}; echo; uid -n %{ns} pod %{pod}; echo; } > "$PRIV/q123.uids"
""",
"checks": [
C("%{sc} is the default StorageClass, and the only one", 2, PY + r"""
py sc '
d = sorted(x["metadata"]["name"] for x in o["items"] if (x["metadata"].get("annotations") or {}).get("storageclass.kubernetes.io/is-default-class") == "true")
d == ["%{sc}"]'"""),
C("the same claim now has class %{sc} and is Bound", 3, STOR + r"""
eq "$(j -n %{ns} pvc %{pvc} '{.metadata.uid}')" "$(sed -n 1p "$PRIV/q123.uids")" || exit 1
bound %{ns} %{pvc} 60 && eq "$(j -n %{ns} pvc %{pvc} '{.spec.storageClassName}')" %{sc}"""),
C("the same Pod runs with the claim mounted", 2, STOR + r"""
eq "$(j -n %{ns} pod %{pod} '{.metadata.uid}')" "$(sed -n 2p "$PRIV/q123.uids")" || exit 1
running %{ns} %{pod} 60"""),
C("class.txt holds %{sc}", 1, STOR + r"""
eq "$(j -n %{ns} pvc %{pvc} '{.spec.storageClassName}')" %{sc} && eq "$(one "$WS/q123/class.txt")" %{sc}"""),
],
"solution": [cmd(r"""k get sc
k -n %{ns} describe pvc %{pvc} | tail -2""", r"""No `(default)` anywhere, and the claim waits: `no storage class is set`."""),
cmd(r"""k annotate sc %{sc} storageclass.kubernetes.io/is-default-class=true
k get sc""", r"""`%{sc} (default)`. A claim without a class gets the default when it is created - or, if there is none, as soon as one appears: no need to recreate it."""),
hand(r"""k -n %{ns} get pvc,pod""",
r"""Again until the claim shows `STORAGECLASS %{sc}` and `Bound`, and the Pod `Running`.""",
run=r"""for i in $(seq 1 40); do [ "$(kubectl -n %{ns} get pvc %{pvc} -o jsonpath='{.status.phase}')" = Bound ] && break; sleep 3; done
kubectl -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=240s"""),
cmd(r"""echo %{sc} > {WS}/q123/class.txt""", r"""Marking `standard` would have bound it too - to the wrong class. With two defaults, new claims take the newest; never leave two.""")],
},
# ------------------------------------------------------------------ 124 ----
{
"id": 124, "domain": "STOR", "workers": 2, "title": "A local volume with a class of its own",
"params": lambda r: {"ns": ns_pick(r, "metrics"), "sc": name_pick(r, "local-disks"), "pv": name_pick(r, "pv-disk"),
"pvc": name_pick(r, "tsdb"), "pod": name_pick(r, "prom"), "tok": f"{r.randint(100000, 999999)}"},
"task": """
A disk has been mounted on node `{W2}` at `/mnt/disks/%{pv}`; it holds a file `disk.id`. Make it
available to Pods as a **local** volume:
1. StorageClass `%{sc}` for volumes that are created by hand: provisioner
`kubernetes.io/no-provisioner`, volume binding mode `WaitForFirstConsumer`.
2. PersistentVolume `%{pv}`: 1Gi, ReadWriteOnce, class `%{sc}`, reclaim policy `Retain`, volume
type `local` with that path, tied to node `{W2}`.
3. In Namespace `%{ns}`: claim `%{pvc}` (1Gi, ReadWriteOnce, class `%{sc}`) and Pod `%{pod}`
(`busybox:1.36`, command `sleep 86400`) that mounts it at `/disk`. Do not tell the Pod where to
run - no nodeSelector, no affinity.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1
$K -n %{ns} delete pvc %{pvc} --ignore-not-found --wait=true >/dev/null 2>&1
$K delete pv %{pv} --ignore-not-found --wait=true >/dev/null 2>&1; $K delete sc %{sc} --ignore-not-found >/dev/null 2>&1
nx "$W2" "mkdir -p /mnt/disks/%{pv} && echo 'disk %{tok}' > /mnt/disks/%{pv}/disk.id" </dev/null || exit 1
""",
"checks": [
C("StorageClass %{sc}: no-provisioner, WaitForFirstConsumer", 2, r"""
eq "$(j sc %{sc} '{.provisioner}/{.volumeBindingMode}')" kubernetes.io/no-provisioner/WaitForFirstConsumer"""),
C("PersistentVolume %{pv}: a local volume at /mnt/disks/%{pv}, tied to {W2}; 1Gi, RWO, Retain", 3, PY + r"""
export W2; py pv %{pv} '
s = o["spec"]
terms = s.get("nodeAffinity", {}).get("required", {}).get("nodeSelectorTerms", [])
nodes = [e.get("values") for t in terms for e in t.get("matchExpressions", []) if e.get("key") == "kubernetes.io/hostname" and e.get("operator") == "In"]
s.get("local", {}).get("path") == "/mnt/disks/%{pv}" and nodes == [[os.environ["W2"]]] and s["capacity"]["storage"] == "1Gi" and s["accessModes"] == ["ReadWriteOnce"] and s.get("persistentVolumeReclaimPolicy") == "Retain" and s.get("storageClassName") == "%{sc}"'"""),
C("claim %{pvc} (1Gi, RWO, %{sc}) is Bound to %{pv}", 2, STOR + r"""
bound %{ns} %{pvc} 60 && eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}/{.spec.storageClassName}/{.spec.resources.requests.storage}')" %{pv}/%{sc}/1Gi"""),
C("Pod %{pod} was sent to {W2} by its volume alone, and reads disk.id at /disk", 3, STOR + r"""
running %{ns} %{pod} 60 || exit 1
eq "$(j -n %{ns} pod %{pod} '{.spec.nodeName}')" "$W2" || exit 1
[ -z "$(j -n %{ns} pod %{pod} '{.spec.nodeSelector}{.spec.affinity}')" ] || exit 1
eq "$($K -n %{ns} exec %{pod} -- cat /disk/disk.id 2>/dev/null | tr -d '\r')" "disk %{tok}" """),
],
"solution": [hand(r"""vim local.yaml""",
r"""The docs page *Storage Classes* (under *Local*) has the class, *Persistent Volumes* a `local` volume with its required node affinity:
```
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: %{sc}
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: %{pv}
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: %{sc}
local:
path: /mnt/disks/%{pv}
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- {W2}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: %{sc}
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```""",
run=r"""cat > local.yaml <<'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: %{sc}
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: %{pv}
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: %{sc}
local:
path: /mnt/disks/%{pv}
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- {W2}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: %{pvc}
namespace: %{ns}
spec:
storageClassName: %{sc}
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
EOF"""),
cmd(r"""k apply -f local.yaml
k -n %{ns} run %{pod} --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > pod.yaml"""),
hand(r"""vim pod.yaml""",
r"""Add to the container, below `name: %{pod}`:
```
volumeMounts:
- name: disk
mountPath: /disk
```
and to the Pod's `spec:`, right below `spec:`:
```
volumes:
- name: disk
persistentVolumeClaim:
claimName: %{pvc}
```""",
run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "disk", "mountPath": "/disk"}]
| .spec.volumes = [{"name": "disk", "persistentVolumeClaim": {"claimName": "%{pvc}"}}]' pod.yaml"""),
cmd(r"""k apply -f pod.yaml"""),
hand(r"""k -n %{ns} get pod %{pod} -o wide""",
r"""Again until `Running` - on `{W2}`, sent there by the volume alone. A `hostPath` is a directory on whatever node the Pod lands on; a `local` volume knows its node, and the scheduler follows it.""",
run=r"""kubectl -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=240s"""),
cmd(r"""k -n %{ns} exec %{pod} -- cat /disk/disk.id""")],
},
]