"""CKA pool tasks 132-147: what a full CKA course teaches beyond the bank as it was - checked on 2026-10-19 against the topics of KubeDojo's CKA course, and written here from scratch. Each has a lesson that teaches it (cka/lessons_*.py); these are the timed tasks that test it. 132 upgrades its cluster from v1.36 to v1.37 for real ("k8s": "v1.36"). 133 drains a node under a PodDisruptionBudget, 134 debugs a container without a shell, 135 issues and withholds ServiceAccount tokens, 136 builds a projected volume, 137 reads QoS classes, 138 resizes a Pod in place, 139 sets the kubelet's eviction thresholds, 140 follows a Service into kube-proxy's rules, 141 backs a Service with a hand-made EndpointSlice, 142 encrypts Secrets at rest, 143 spreads replicas with anti-affinity, 144 rolls a StatefulSet out behind a partition, 145 makes an operator with broken RBAC do its job, and 146-147 grow, snapshot, restore and clone volumes on the CSI hostpath driver's class `csi-hostpath-sc`, as the real exam's clusters have it (ensure_csi_hostpath). """ from cka.common import C, CAN, NET, NS, ONE, backup_operator, ns_pick, name_pick _ETCD = ("ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt " "--cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key") # packages of a node, and whether they are held APT = r""" pkgs() { nx "$1" "dpkg-query -W -f='\${Version} ' kubelet kubeadm kubectl" /dev/null $K -n %{ns} rollout status deploy web --timeout=180s >/dev/null """, "checks": [ C("the API server runs v1.37.1", 3, r""" eq "$($K version -o json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["serverVersion"]["gitVersion"])')" v1.37.1"""), C("{CP}: kubelet v1.37.1; kubelet, kubeadm, kubectl packages 1.37.1 and held", 2, APT + r""" eq "$(j node "$CP" '{.status.nodeInfo.kubeletVersion}')" v1.37.1 && eq "$(pkgs "$CP")" "1.37.1-1.1 1.37.1-1.1 1.37.1-1.1 " && held "$CP" """), C("{W1}: kubelet v1.37.1; kubelet, kubeadm, kubectl packages 1.37.1 and held", 3, APT + r""" eq "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" v1.37.1 && eq "$(pkgs "$W1")" "1.37.1-1.1 1.37.1-1.1 1.37.1-1.1 " && held "$W1" """), C("both nodes Ready and schedulable, Deployment web available", 2, r""" for n in "$CP" "$W1"; do eq "$(j node "$n" '{.status.conditions[?(@.type=="Ready")].status}')" True && [ "$(j node "$n" '{.spec.unschedulable}')" != true ] || exit 1 done eq "$(j node "$W1" '{.status.nodeInfo.kubeletVersion}')" v1.37.1 && eq "$(j -n %{ns} deploy web '{.status.availableReplicas}')" 2"""), ], "solution": """ # the control plane - this host sudo sed -i 's|/v1.36/|/v1.37/|' /etc/apt/sources.list.d/kubernetes.list # one repository per minor sudo apt-get update apt-cache madison kubeadm | head -3 sudo apt-mark unhold kubeadm && sudo apt-get install -y kubeadm=1.37.1-1.1 && sudo apt-mark hold kubeadm sudo kubeadm upgrade plan sudo kubeadm upgrade apply v1.37.1 -y # a few minutes k drain {CP} --ignore-daemonsets --delete-emptydir-data sudo apt-mark unhold kubelet kubectl && sudo apt-get install -y kubelet=1.37.1-1.1 kubectl=1.37.1-1.1 && sudo apt-mark hold kubelet kubectl sudo systemctl daemon-reload && sudo systemctl restart kubelet k uncordon {CP} # the worker k drain {W1} --ignore-daemonsets --delete-emptydir-data {SHELL_W1} sudo -i sed -i 's|/v1.36/|/v1.37/|' /etc/apt/sources.list.d/kubernetes.list apt-get update apt-mark unhold kubeadm kubelet kubectl apt-get install -y kubeadm=1.37.1-1.1 kubeadm upgrade node # a worker: node, not apply apt-get install -y kubelet=1.37.1-1.1 kubectl=1.37.1-1.1 apt-mark hold kubeadm kubelet kubectl systemctl daemon-reload && systemctl restart kubelet exit; exit k uncordon {W1} k get nodes # docs: "Upgrading kubeadm clusters". pkgs.k8s.io has one repository per minor, so 1.37 packages # appear only once the line names v1.37. kubeadm first - it drives the upgrade and rewrites the # kubelet's configuration; kubelet and kubectl after `upgrade apply`, then the restart: `k get # nodes` shows each kubelet's running version. Workers run `kubeadm upgrade node`. """, }, # ------------------------------------------------------------------ 133 ---- { "id": 133, "domain": "WORK", "workers": 2, "title": "Maintenance under a disruption budget", "params": lambda r: {"ns": ns_pick(r, "orders"), "dep": name_pick(r, "checkout")}, "task": """ Node `{W1}` goes down for maintenance tonight. Deployment `%{dep}` in `%{ns}` (3 replicas) must never have fewer than **2** Pods available while that happens - not for a drain, and not for any other voluntary disruption. 1. Create a PodDisruptionBudget `%{dep}` in `%{ns}` that guarantees it. 2. Drain `{W1}`. DaemonSet Pods stay where they are; Pod `scratch` in `%{ns}` is disposable. 3. Leave `{W1}` unschedulable. All three Pods of `%{dep}` must be running, on other nodes. """, "setup": NS + r""" mkns %{ns} $K uncordon "$W1" >/dev/null 2>&1 $K -n %{ns} apply -f - >/dev/null </dev/null && $K -n %{ns} wait --for=condition=Ready pod/scratch --timeout=120s >/dev/null """, "checks": [ C("PDB %{dep} keeps 2 of the 3 Pods available", 2, r""" eq "$(j -n %{ns} pdb %{dep} '{.spec.selector.matchLabels.app}')" %{dep} || exit 1 m=$(j -n %{ns} pdb %{dep} '{.spec.minAvailable}'); u=$(j -n %{ns} pdb %{dep} '{.spec.maxUnavailable}') [ "$m" = 2 ] || [ "$u" = 1 ]"""), C("{W1} is drained and stays unschedulable", 2, r""" eq "$(j node "$W1" '{.spec.unschedulable}')" true || exit 1 [ -z "$($K -n %{ns} get pods -o jsonpath="{range .items[?(@.spec.nodeName==\"$W1\")]}{.metadata.name}{end}" 2>/dev/null)" ]"""), C("all 3 Pods of %{dep} are available on other nodes", 3, r""" eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 || exit 1 for n in $($K -n %{ns} get pods -l app=%{dep} -o jsonpath='{.items[*].spec.nodeName}'); do [ "$n" != "$W1" ] || exit 1; done eq "$(j node "$W1" '{.spec.unschedulable}')" true"""), ], "solution": """ k -n %{ns} create pdb %{dep} --selector=app=%{dep} --min-available=2 k -n %{ns} get pods -o wide # which of them run on {W1} k drain {W1} --ignore-daemonsets --delete-emptydir-data --force k -n %{ns} get pods -o wide # drain = cordon + evict. Evictions respect the PDB: with 3 available and minAvailable 2, one Pod # may go at a time, and the drain retries the others until their replacements are ready. --force # is needed for `scratch`, which no controller would re-create - it is deleted for good. Leave the # node cordoned: `k uncordon {W1}` is for after the maintenance. """, }, # ------------------------------------------------------------------ 134 ---- { "id": 134, "domain": "TSHOOT", "workers": 1, "title": "A container you cannot exec into", "params": lambda r: {"ns": ns_pick(r, "vault"), "pod": name_pick(r, "sealer"), "mode": r.choice(["sealed-3", "sealed-7", "sealed-9"])}, "task": """ Pod `%{pod}` in `%{ns}` runs an image that holds nothing but its own program: `kubectl exec` finds no shell. Its container `app` takes its environment from ConfigMap `%{pod}-env` - which someone has edited since the container started. Without restarting or replacing the Pod: 1. Attach an ephemeral container named `debugger` (image `busybox:1.36`) that sees the processes of container `app`. 2. Find out which value of `MODE` the running process of `app` actually has, and write it to `{WS}/q134/mode.txt`. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q134"; mkdir -p "$WS/q134" $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1 $K -n %{ns} create configmap %{pod}-env --from-literal=MODE=%{mode} --dry-run=client -o yaml | $K apply -f - >/dev/null $K -n %{ns} apply -f - >/dev/null <<'EOF' apiVersion: v1 kind: Pod metadata: {name: %{pod}} spec: containers: - name: app image: registry.k8s.io/pause:3.10.2 envFrom: [{configMapRef: {name: %{pod}-env}}] EOF $K -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=120s >/dev/null || exit 1 $K -n %{ns} create configmap %{pod}-env --from-literal=MODE=open --dry-run=client -o yaml | $K apply -f - >/dev/null j -n %{ns} pod %{pod} '{.metadata.uid}' > "$PRIV/q134.uid" """, "checks": [ C("ephemeral container debugger (busybox:1.36) targets app - in the same Pod, never restarted", 3, r""" eq "$(j -n %{ns} pod %{pod} '{.spec.ephemeralContainers[?(@.name=="debugger")].targetContainerName} {.spec.ephemeralContainers[?(@.name=="debugger")].image}')" "app busybox:1.36" \ && eq "$(j -n %{ns} pod %{pod} '{.metadata.uid} {.status.containerStatuses[0].restartCount}')" "$(cat "$PRIV/q134.uid") 0" """), C("mode.txt holds the value the process runs with", 3, ONE + r"""eq "$(one "$WS/q134/mode.txt")" %{mode}"""), ], "solution": """ k -n %{ns} exec %{pod} -- sh # fails: no shell in the image k -n %{ns} get cm %{pod}-env -o yaml # MODE=open - but that is the ConfigMap NOW k -n %{ns} debug %{pod} --image=busybox:1.36 --target=app -c debugger --profile=general -- sleep 3600 for i in $(seq 1 30); do k -n %{ns} exec %{pod} -c debugger -- true 2>/dev/null && break; sleep 2; done k -n %{ns} exec %{pod} -c debugger -- ps # PID 1 is /pause, the app's process k -n %{ns} exec %{pod} -c debugger -- sh -c 'tr "\\0" "\\n" < /proc/1/environ' | grep '^MODE=' k -n %{ns} exec %{pod} -c debugger -- sh -c 'tr "\\0" "\\n" < /proc/1/environ' | sed -n 's/^MODE=//p' > {WS}/q134/mode.txt # Environment variables from a ConfigMap are read once, when the container starts: editing the # ConfigMap changes nothing for a running process. --target puts the debug container into app's # process namespace, so /proc/1 is app's process; the `general` profile gives it SYS_PTRACE, # which reading another user's /proc/PID/environ needs. Interactively: `k debug -it ... -- sh`. """, }, # ------------------------------------------------------------------ 135 ---- { "id": 135, "domain": "ARCH", "title": "A token for the pipeline, none for the app", "params": lambda r: {"ns": ns_pick(r, "ci"), "h": r.choice([2, 3, 4])}, "task": """ In Namespace `%{ns}`: 1. Create ServiceAccount `pipeline`, and allow it - through a Role and a RoleBinding, both named `pipeline` - to `get`, `list`, `create`, `update` and `patch` Deployments, and nothing else. 2. Issue a token for `pipeline` that is valid for **%{h} hours**, and save just the token in `{WS}/q135/pipeline.token`. 3. Deployment `frontend` never talks to the API server. Change it so that its Pods get **no** ServiceAccount token at all. It keeps running as the `default` ServiceAccount. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q135"; mkdir -p "$WS/q135" $K -n %{ns} create deploy frontend --image=nginx:1.27 --replicas=2 >/dev/null $K -n %{ns} rollout status deploy frontend --timeout=180s >/dev/null """, "checks": [ C("pipeline may get, list, create, update, patch Deployments - nothing more", 2, CAN + r""" S=system:serviceaccount:%{ns}:pipeline for v in get list create update patch; do can --as=$S -- $v deployments.apps -n %{ns} || exit 1; done cannot --as=$S -- delete deployments.apps -n %{ns} && cannot --as=$S -- get secrets -n %{ns} && cannot --as=$S -- list pods -n %{ns} \ && eq "$(j -n %{ns} rolebinding pipeline '{.roleRef.kind}/{.roleRef.name}')" Role/pipeline"""), C("pipeline.token authenticates as pipeline and lasts %{h} hours", 3, r""" t=$(tr -d ' \r\n' < "$WS/q135/pipeline.token" 2>/dev/null); [ -n "$t" ] || exit 1 srv=$($K config view --minify -o jsonpath='{.clusters[0].cluster.server}') eq "$($K --kubeconfig=/dev/null --server="$srv" --insecure-skip-tls-verify --token="$t" auth whoami -o jsonpath='{.status.userInfo.username}' 2>/dev/null)" system:serviceaccount:%{ns}:pipeline || exit 1 echo "$t" | cut -d. -f2 | tr '_-' '/+' | python3 -c ' import base64, json, sys s = sys.stdin.read().strip(); c = json.loads(base64.b64decode(s + "=" * (-len(s) % 4))) sys.exit(0 if c["exp"] - c["iat"] == %{h} * 3600 else 1)'"""), C("frontend's Pods run with no ServiceAccount token, as default", 3, r""" eq "$(j -n %{ns} deploy frontend '{.status.availableReplicas} {.status.updatedReplicas}')" "2 2" || exit 1 sa=$(j -n %{ns} deploy frontend '{.spec.template.spec.serviceAccountName}'); [ -z "$sa" ] || [ "$sa" = default ] || exit 1 for p in $($K -n %{ns} get pods -l app=frontend -o name); do $K -n %{ns} get "$p" -o json | python3 -c ' import json, sys; s = json.load(sys.stdin)["spec"] tok = [v for v in s.get("volumes", []) if any("serviceAccountToken" in src for src in v.get("projected", {}).get("sources", []))] sys.exit(1 if tok else 0)' || exit 1 done"""), ], "solution": """ k -n %{ns} create serviceaccount pipeline k -n %{ns} create role pipeline --verb=get,list,create,update,patch --resource=deployments.apps k -n %{ns} create rolebinding pipeline --role=pipeline --serviceaccount=%{ns}:pipeline k auth can-i delete deployments -n %{ns} --as=system:serviceaccount:%{ns}:pipeline # no k -n %{ns} create token pipeline --duration=%{h}h > {WS}/q135/pipeline.token k -n %{ns} patch deploy frontend -p '{"spec":{"template":{"spec":{"automountServiceAccountToken":false}}}}' k -n %{ns} rollout status deploy frontend # `create token` asks the TokenRequest API for a short-lived, bound token - nothing is stored in a # Secret. automountServiceAccountToken: false on the Pod template (or on the ServiceAccount; the # Pod's setting wins) leaves out the projected volume kube-api-access-*. To try a token alone, use # no kubeconfig: a client certificate in yours would be checked first. """, }, # ------------------------------------------------------------------ 136 ---- { "id": 136, "domain": "STOR", "title": "One mount, four sources", "params": lambda r: {"ns": ns_pick(r, "portal"), "pod": name_pick(r, "portal"), "mem": r.choice([96, 128, 160])}, "task": """ Create Pod `%{pod}` in `%{ns}`: image `busybox:1.36`, command `sleep 86400`, label `app=%{pod}`, memory limit `%{mem}Mi`. Mount a **single** volume, read-only, at `/etc/portal`, that holds: - every key of ConfigMap `portal-config`, each as a file of its name; - key `password` of Secret `portal-db`, as the file `db/password`; - the Pod's labels, as the file `labels`; - the container's memory limit in MiB, as the file `mem-limit` (just the number). """, "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} create configmap portal-config --from-literal=theme=dark --from-literal=locale=en_GB --dry-run=client -o yaml | $K apply -f - >/dev/null $K -n %{ns} create secret generic portal-db --from-literal=password=pw-%{mem}-x --from-literal=user=portal --dry-run=client -o yaml | $K apply -f - >/dev/null """, "checks": [ C("one projected volume, read-only at /etc/portal", 2, r""" eq "$(j -n %{ns} pod %{pod} '{.spec.containers[0].volumeMounts[?(@.mountPath=="/etc/portal")].readOnly}')" true || exit 1 v=$(j -n %{ns} pod %{pod} '{.spec.containers[0].volumeMounts[?(@.mountPath=="/etc/portal")].name}') [ -n "$(j -n %{ns} pod %{pod} "{.spec.volumes[?(@.name==\"$v\")].projected.sources}")" ]"""), C("the ConfigMap's keys and db/password are files there", 2, r""" x() { $K -n %{ns} exec %{pod} -- cat "/etc/portal/$1" 2>/dev/null; } eq "$(x theme)" dark && eq "$(x locale)" en_GB && eq "$(x db/password)" pw-%{mem}-x && ! $K -n %{ns} exec %{pod} -- test -e /etc/portal/user"""), C("labels and mem-limit come from the downward API", 3, r""" x() { $K -n %{ns} exec %{pod} -- cat "/etc/portal/$1" 2>/dev/null; } x labels | grep -qx 'app="%{pod}"' && eq "$(x mem-limit)" %{mem}"""), ], "solution": """ k -n %{ns} apply -f - <<'EOF' apiVersion: v1 kind: Pod metadata: name: %{pod} labels: {app: %{pod}} spec: containers: - name: portal image: busybox:1.36 command: [sleep, '86400'] resources: limits: {memory: %{mem}Mi} volumeMounts: - {name: portal, mountPath: /etc/portal, readOnly: true} volumes: - name: portal projected: sources: - configMap: name: portal-config - secret: name: portal-db items: - {key: password, path: db/password} - downwardAPI: items: - path: labels fieldRef: {fieldPath: metadata.labels} - path: mem-limit resourceFieldRef: {containerName: portal, resource: limits.memory, divisor: 1Mi} EOF k -n %{ns} wait --for=condition=Ready pod/%{pod} k -n %{ns} exec %{pod} -- sh -c 'ls -R /etc/portal; cat /etc/portal/labels /etc/portal/mem-limit' # `items` picks keys and renames them: only `password` from the Secret, as db/password. A # resourceFieldRef needs the container's name in a volume (in an env var it is implied), and the # divisor turns bytes into MiB. """, }, # ------------------------------------------------------------------ 137 ---- { "id": 137, "domain": "WORK", "title": "Who is evicted first", "params": lambda r: {"ns": ns_pick(r, "batch")}, "task": """ Namespace `%{ns}` runs Pods `alpha`, `beta`, `gamma` and `delta`, and Deployment `critical`. 1. Write the QoS class of each of the four Pods to `{WS}/q137/qos.txt`, one per line, as `POD CLASS`. 2. When the node runs short of memory, which of the four does the kubelet evict **first**? Write its name to `{WS}/q137/first.txt`. 3. Change Deployment `critical` so that its Pods are of class `Guaranteed`, with `200m` CPU and `128Mi` memory. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q137"; mkdir -p "$WS/q137" $K -n %{ns} delete pod alpha beta gamma delta --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1 $K -n %{ns} apply -f - >/dev/null <<'EOF' apiVersion: v1 kind: Pod metadata: {name: alpha} spec: containers: [{name: c, image: busybox:1.36, command: [sleep, '86400'], resources: {requests: {cpu: 50m, memory: 32Mi}, limits: {cpu: 50m, memory: 32Mi}}}] --- apiVersion: v1 kind: Pod metadata: {name: beta} spec: containers: [{name: c, image: busybox:1.36, command: [sleep, '86400'], resources: {requests: {memory: 32Mi}}}] --- apiVersion: v1 kind: Pod metadata: {name: gamma} spec: containers: [{name: c, image: busybox:1.36, command: [sleep, '86400']}] --- apiVersion: v1 kind: Pod metadata: {name: delta} spec: containers: - {name: main, image: busybox:1.36, command: [sleep, '86400'], resources: {requests: {cpu: 50m, memory: 32Mi}, limits: {cpu: 50m, memory: 32Mi}}} - {name: helper, image: busybox:1.36, command: [sleep, '86400']} --- apiVersion: apps/v1 kind: Deployment metadata: {name: critical} spec: replicas: 1 selector: {matchLabels: {app: critical}} template: metadata: {labels: {app: critical}} spec: containers: [{name: app, image: nginx:1.27, resources: {requests: {cpu: 100m, memory: 64Mi}}}] EOF $K -n %{ns} wait --for=condition=Ready pod --all --timeout=180s >/dev/null """, "checks": [ C("qos.txt names each Pod's class", 3, r""" eq "$(tr -s ' \t' ' ' < "$WS/q137/qos.txt" 2>/dev/null | tr -d '\r' | sed 's/ *$//' | grep . | sort)" "$(printf 'alpha Guaranteed\nbeta Burstable\ndelta Burstable\ngamma BestEffort')" """), C("first.txt names the Pod evicted first", 2, ONE + r"""eq "$(one "$WS/q137/first.txt")" gamma"""), C("critical's Pods are Guaranteed with 200m and 128Mi", 3, r""" eq "$(j -n %{ns} deploy critical '{.status.availableReplicas} {.status.updatedReplicas}')" "1 1" || exit 1 eq "$(j -n %{ns} deploy critical '{.spec.template.spec.containers[0].resources.requests.cpu} {.spec.template.spec.containers[0].resources.limits.cpu} {.spec.template.spec.containers[0].resources.requests.memory} {.spec.template.spec.containers[0].resources.limits.memory}')" "200m 200m 128Mi 128Mi" || exit 1 for q in $($K -n %{ns} get pods -l app=critical -o jsonpath='{.items[*].status.qosClass}'); do [ "$q" = Guaranteed ] || exit 1; done"""), ], "solution": """ k -n %{ns} get pods -o custom-columns=NAME:.metadata.name,QOS:.status.qosClass k -n %{ns} get pods alpha beta delta gamma -o jsonpath='{range .items[*]}{.metadata.name} {.status.qosClass}{"\\n"}{end}' > {WS}/q137/qos.txt echo gamma > {WS}/q137/first.txt k -n %{ns} set resources deploy critical --requests=cpu=200m,memory=128Mi --limits=cpu=200m,memory=128Mi k -n %{ns} rollout status deploy critical k -n %{ns} get pods -l app=critical -o jsonpath='{.items[*].status.qosClass}'; echo # Guaranteed: every container has requests equal to limits for both CPU and memory. BestEffort: no # container has any. Everything else is Burstable - delta too, because one container sets nothing. # Under memory pressure the kubelet evicts BestEffort first, then Burstable Pods using more than # they requested; Guaranteed Pods last. """, }, # ------------------------------------------------------------------ 138 ---- { "id": 138, "domain": "WORK", "title": "More CPU, same Pod", "params": lambda r: {"ns": ns_pick(r, "render"), "pod": name_pick(r, "encoder"), "req": r.choice(["250m", "300m"]), "lim": r.choice(["500m", "600m"])}, "task": """ Pod `%{pod}` in `%{ns}` is throttled at its CPU limit. Give its container `encoder` a CPU request of `%{req}` and a CPU limit of `%{lim}` - **without** replacing the Pod and without restarting the container. Its memory request and limit stay as they are. """, "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} apply -f - >/dev/null <<'EOF' apiVersion: v1 kind: Pod metadata: {name: %{pod}} spec: containers: - name: encoder image: busybox:1.36 command: [sh, -c, 'while true; do :; done'] resources: {requests: {cpu: 100m, memory: 48Mi}, limits: {cpu: 200m, memory: 48Mi}} EOF $K -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=120s >/dev/null || exit 1 j -n %{ns} pod %{pod} '{.metadata.uid}' > "$PRIV/q138.uid" """, "checks": [ C("its spec asks for %{req} / %{lim} CPU, memory unchanged", 2, r""" eq "$(j -n %{ns} pod %{pod} '{.spec.containers[0].resources.requests.cpu} {.spec.containers[0].resources.limits.cpu} {.spec.containers[0].resources.requests.memory} {.spec.containers[0].resources.limits.memory}')" "%{req} %{lim} 48Mi 48Mi" """), C("the running container has the new CPU limit", 2, r""" eq "$(j -n %{ns} pod %{pod} '{.status.containerStatuses[0].resources.requests.cpu} {.status.containerStatuses[0].resources.limits.cpu}')" "%{req} %{lim}" """), C("in the same Pod, and its container never restarted", 2, r""" eq "$(j -n %{ns} pod %{pod} '{.spec.containers[0].resources.limits.cpu}')" %{lim} \ && eq "$(j -n %{ns} pod %{pod} '{.metadata.uid} {.status.containerStatuses[0].restartCount}')" "$(cat "$PRIV/q138.uid") 0" """), ], "solution": """ k -n %{ns} get pod %{pod} -o jsonpath='{.spec.containers[0].resources}'; echo k -n %{ns} patch pod %{pod} --subresource resize --patch '{"spec":{"containers":[{"name":"encoder","resources":{"requests":{"cpu":"%{req}"},"limits":{"cpu":"%{lim}"}}}]}}' k -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].resources}{"\\n"}{.status.containerStatuses[0].restartCount}{"\\n"}' # A running Pod's CPU and memory can be changed in place, through the `resize` subresource - a # plain `k edit` or `k apply` of a Pod's resources is refused. The spec # says what you asked for; status.containerStatuses[].resources what the kubelet has applied. # A container's resizePolicy decides whether a resource's change needs a restart (CPU: no). """, }, # ------------------------------------------------------------------ 139 ---- { "id": 139, "domain": "TSHOOT", "workers": 1, "title": "Evict before the node runs dry", "params": lambda r: {"mem": r.choice([200, 250, 300]), "fs": r.choice([10, 12, 15]), "soft": r.choice([400, 500])}, "task": """ Node `{W1}` ran out of memory last week and froze before its kubelet did anything about it. Configure the kubelet on `{W1}` - in its configuration file, so that it lasts - to: - evict Pods at once when less than `%{mem}Mi` of memory, or less than `%{fs}%` of the node filesystem, is available; - evict Pods when less than `%{soft}Mi` of memory has been available for **90 seconds**. Leave its other settings as they are. The node must be `Ready` afterwards. Reach it with `{SHELL_W1}`. """, "setup": r""" nx "$W1" 'cp -n /var/lib/kubelet/config.yaml /var/lib/kubelet/config.yaml.ksim 2>/dev/null; true' /dev/null | python3 -c ' import json, sys; c = json.load(sys.stdin)["kubeletconfig"]; e = c.get("evictionHard") or {} sys.exit(0 if e.get("memory.available") == "%{mem}Mi" and e.get("nodefs.available") == "%{fs}%" else 1)'"""), C("soft threshold memory.available %{soft}Mi with a 90 s grace period", 3, r""" $K get --raw "/api/v1/nodes/$W1/proxy/configz" 2>/dev/null | python3 -c ' import json, re, sys; c = json.load(sys.stdin)["kubeletconfig"] s = (c.get("evictionSoft") or {}).get("memory.available"); g = (c.get("evictionSoftGracePeriod") or {}).get("memory.available", "") m = re.fullmatch(r"(?:(\d+)m)?(?:(\d+)s)?", g); secs = int(m.group(1) or 0) * 60 + int(m.group(2) or 0) if m and g else -1 sys.exit(0 if s == "%{soft}Mi" and secs == 90 else 1)'"""), C("set in the file, the other settings kept, the node Ready", 2, r""" nx "$W1" 'grep -q "%{soft}Mi" /var/lib/kubelet/config.yaml && grep -q "%{mem}Mi" /var/lib/kubelet/config.yaml && grep -q "^staticPodPath:" /var/lib/kubelet/config.yaml && systemctl is-active -q kubelet' /dev/null $K -n %{ns} expose deploy %{svc} --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null $K -n %{ns} rollout status deploy %{svc} --timeout=180s >/dev/null """, "checks": [ C("mode.txt: kube-proxy's mode", 2, ONE + r""" m=$($K -n kube-system get cm kube-proxy -o jsonpath='{.data.config\.conf}' | awk '/^mode:/ {print $2}' | tr -d '"'); m=${m:-iptables} eq "$(one "$WS/q140/mode.txt" | tr -d '"')" "$m" """), C("chain.txt: the Service's KUBE-SVC chain", 3, ONE + r""" ip=$(j -n %{ns} svc %{svc} '{.spec.clusterIP}') want=$(nx "$CP" "iptables-save -t nat | grep -- '-d $ip/32' | grep -o 'KUBE-SVC-[A-Z0-9]*' | head -1" {WS}/q140/mode.txt ip=$(k -n %{ns} get svc %{svc} -o jsonpath='{.spec.clusterIP}') sudo iptables-save -t nat | grep -- "-d $ip/32" # KUBE-SERVICES ... -j KUBE-SVC-XXXX chain=$(sudo iptables-save -t nat | grep -- "-d $ip/32" | grep -o 'KUBE-SVC-[A-Z0-9]*' | head -1) echo $chain > {WS}/q140/chain.txt sudo iptables-save -t nat | grep -- "^-A $chain " # one -j KUBE-SEP-... per endpoint sudo iptables-save -t nat | grep -- "^-A $chain " | grep -c -- '-j KUBE-SEP-' > {WS}/q140/endpoints.txt # KUBE-SERVICES matches ClusterIP:port and jumps to the Service's KUBE-SVC chain; that picks an # endpoint chain at random (statistic mode random), and each KUBE-SEP chain DNATs to one Pod. # One KUBE-SEP per ready endpoint. In nftables mode: `sudo nft list ruleset | grep -A5 %{svc}`. """, }, # ------------------------------------------------------------------ 141 ---- { "id": 141, "domain": "NET", "workers": 1, "title": "A Service for an API outside the cluster", "params": lambda r: {"ns": ns_pick(r, "apps"), "port": r.choice([8080, 9090])}, "task": """ The billing API is not part of the cluster: it answers at the address and port written in `{WS}/q141/billing.txt`. Give the applications in `%{ns}` a stable name for it: 1. Create Service `billing` in `%{ns}`, port `80`, **without** a selector. 2. Its endpoints are yours to maintain: create EndpointSlice `billing-1` that sends its traffic to that address and port. `wget -qO- billing` from Pod `client` in `%{ns}` must return the API's answer. """, "setup": NS + r""" mkns %{ns}; mkns billing-host; rm -rf "$WS/q141"; mkdir -p "$WS/q141" $K -n %{ns} delete svc billing --ignore-not-found >/dev/null 2>&1 $K -n %{ns} run client --image=busybox:1.36 -- sleep 86400 >/dev/null 2>&1 $K -n billing-host run api --image=busybox:1.36 --overrides="{\"spec\":{\"hostNetwork\":true,\"nodeName\":\"$W1\"}}" --command -- \ sh -c 'mkdir -p /www && echo billing-api-%{port} > /www/index.html && exec httpd -f -p %{port} -h /www' >/dev/null 2>&1 $K -n %{ns} wait --for=condition=Ready pod/client --timeout=120s >/dev/null && $K -n billing-host wait --for=condition=Ready pod/api --timeout=120s >/dev/null || exit 1 echo "$(j node "$W1" '{.status.addresses[?(@.type=="InternalIP")].address}'):%{port}" > "$WS/q141/billing.txt" """, "checks": [ C("Service billing, port 80, has no selector", 1, r"""eq "$(j -n %{ns} svc billing '{.spec.ports[0].port}')" 80 && [ -z "$(j -n %{ns} svc billing '{.spec.selector}')" ]"""), C("EndpointSlice billing-1 belongs to it and holds the API's address and port", 3, r""" a=$(cut -d: -f1 "$WS/q141/billing.txt") eq "$(j -n %{ns} endpointslice billing-1 '{.metadata.labels.kubernetes\.io/service-name} {.addressType} {.endpoints[0].addresses[0]} {.ports[0].port}')" "billing IPv4 $a %{port}" """), C("wget billing from client returns the API's answer", 3, r""" for i in 1 2 3 4 5; do [ "$($K -n %{ns} exec client -- wget -qO- -T 3 billing 2>/dev/null)" = billing-api-%{port} ] && exit 0; sleep 3; done; exit 1"""), ], "solution": """ cat {WS}/q141/billing.txt # ADDRESS:PORT addr=$(cut -d: -f1 {WS}/q141/billing.txt); port=$(cut -d: -f2 {WS}/q141/billing.txt) k -n %{ns} apply -f - </dev/null """, "checks": [ C("enc.yaml encrypts secrets with aescbc key1 first, identity after", 1, r""" nx "$CP" 'cat /etc/kubernetes/enc/enc.yaml' /dev/null | python3 -c ' import sys, yaml c = yaml.safe_load(sys.stdin); r = [x for x in c["resources"] if "secrets" in x["resources"]][0]["providers"] sys.exit(0 if "aescbc" in r[0] and r[0]["aescbc"]["keys"][0]["name"] == "key1" and any("identity" in p for p in r[1:]) else 1)' 2>/dev/null \ || nx "$CP" 'grep -q aescbc /etc/kubernetes/enc/enc.yaml && grep -q "name: key1" /etc/kubernetes/enc/enc.yaml && grep -q identity /etc/kubernetes/enc/enc.yaml' /dev/null | head -c 64' /dev/null 2>&1 $K -n %{ns} create secret generic ksim-probe --from-literal=x=y >/dev/null 2>&1 || exit 1 nx "$CP" '""" + _ETCD + r""" get /registry/secrets/%{ns}/ksim-probe --print-value-only 2>/dev/null | head -c 64' /dev/null 2>&1; exit $r"""), ], "solution": """ sudo mkdir -p /etc/kubernetes/enc sudo tee /etc/kubernetes/enc/enc.yaml >/dev/null </dev/null 2>&1 && break; sleep 3; done k -n %{ns} get secret %{sec} -o json | k replace -f - # stored again: encrypted now sudo ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt \\ --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key \\ get /registry/secrets/%{ns}/%{sec} | od -c | head -3 # k 8 s : e n c : a e s c b c : v 1 : k e y 1 # By hand: in kube-apiserver.yaml add the flag to the command, a volumeMount for /etc/kubernetes/enc # and a hostPath volume of the same path - the API server runs in a container and sees only what is # mounted. The FIRST provider encrypts; `identity` after it reads what was stored before. Every # Secret: `k get secrets -A -o json | k replace -f -`. """, }, # ------------------------------------------------------------------ 143 ---- { "id": 143, "domain": "WORK", "workers": 3, "title": "Never two on one node", "params": lambda r: {"ns": ns_pick(r, "edge"), "dep": name_pick(r, "gateway")}, "task": """ Deployment `%{dep}` in `%{ns}` runs 3 replicas, and nothing stops two of them from landing on the same node. Change it so that: - no two of its Pods ever run on the same node - a hard rule, not a preference; - among the nodes allowed, those labelled `tier=edge` are **preferred**. Label `{W2}` and `{W3}` with `tier=edge`. All three replicas must be running afterwards. """, "setup": NS + r""" mkns %{ns} for n in "$W1" "$W2" "$W3"; do $K label node "$n" tier- >/dev/null 2>&1; done $K -n %{ns} create deploy %{dep} --image=nginx:1.27 --replicas=3 --dry-run=client -o yaml | $K apply -f - >/dev/null $K -n %{ns} rollout status deploy %{dep} --timeout=180s >/dev/null """, "checks": [ C("required pod anti-affinity on kubernetes.io/hostname for its own Pods", 3, r""" $K -n %{ns} get deploy %{dep} -o json 2>/dev/null | python3 -c ' import json, sys; d = json.load(sys.stdin); t = d["spec"]["template"] a = t["spec"].get("affinity", {}).get("podAntiAffinity", {}).get("requiredDuringSchedulingIgnoredDuringExecution", []) lab = t["metadata"]["labels"] ok = any(x.get("topologyKey") == "kubernetes.io/hostname" and all(lab.get(k) == v for k, v in x.get("labelSelector", {}).get("matchLabels", {}).items()) and x.get("labelSelector", {}).get("matchLabels") for x in a) sys.exit(0 if ok else 1)'"""), C("preferred node affinity for tier=edge; {W2} and {W3} labelled", 2, r""" eq "$(j node "$W2" '{.metadata.labels.tier}') $(j node "$W3" '{.metadata.labels.tier}')" "edge edge" || exit 1 $K -n %{ns} get deploy %{dep} -o json 2>/dev/null | python3 -c ' import json, sys; s = json.load(sys.stdin)["spec"]["template"]["spec"] p = s.get("affinity", {}).get("nodeAffinity", {}).get("preferredDuringSchedulingIgnoredDuringExecution", []) ok = any(e.get("key") == "tier" and e.get("operator") == "In" and "edge" in e.get("values", []) for x in p for e in x["preference"].get("matchExpressions", [])) sys.exit(0 if ok else 1)'"""), C("3 Pods of the new template running on 3 different nodes", 2, r""" [ -n "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution}')" ] || exit 1 eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas} {.status.updatedReplicas} {.status.replicas}')" "3 3 3" || exit 1 [ "$($K -n %{ns} get pods -l app=%{dep} --field-selector=status.phase=Running -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | sort -u | grep -c .)" = 3 ]"""), ], "solution": """ k label node {W2} {W3} tier=edge k -n %{ns} patch deploy %{dep} --type merge -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":0,"maxUnavailable":1}},"template":{"spec":{"affinity":{ "podAntiAffinity":{"requiredDuringSchedulingIgnoredDuringExecution":[{"labelSelector":{"matchLabels":{"app":"%{dep}"}},"topologyKey":"kubernetes.io/hostname"}]}, "nodeAffinity":{"preferredDuringSchedulingIgnoredDuringExecution":[{"weight":100,"preference":{"matchExpressions":[{"key":"tier","operator":"In","values":["edge"]}]}}]}}}}}}' k -n %{ns} rollout status deploy %{dep} k -n %{ns} get pods -o wide # The anti-affinity's labelSelector picks the Pods to stay away from - its own, by its template's # labels - and topologyKey says per what: one per hostname = one per node. With 3 workers a 4th # replica would stay Pending - and so does the rolling update's surge Pod, since every node already # has one of the old Pods (same labels). The default maxUnavailable of 25% is 0 Pods for 3 # replicas, so the rollout would wait for ever: let it replace one Pod at a time instead # (maxSurge 0, maxUnavailable 1), in the same change. """, }, # ------------------------------------------------------------------ 144 ---- { "id": 144, "domain": "WORK", "title": "A canary for a StatefulSet", "params": lambda r: {"ns": ns_pick(r, "store"), "sts": name_pick(r, "cache")}, "task": """ StatefulSet `%{sts}` in `%{ns}` runs `nginx:1.26` on 3 replicas. Roll out `nginx:1.27` as a canary: only the replica with the **highest ordinal** gets the new image now; the other two keep running `nginx:1.26`, untouched, until someone lowers the partition. """, "setup": NS + r""" mkns %{ns} $K -n %{ns} apply -f - >/dev/null <<'EOF' apiVersion: v1 kind: Service metadata: {name: %{sts}} spec: {clusterIP: None, selector: {app: %{sts}}, ports: [{port: 80}]} --- apiVersion: apps/v1 kind: StatefulSet metadata: {name: %{sts}} spec: serviceName: %{sts} replicas: 3 selector: {matchLabels: {app: %{sts}}} template: metadata: {labels: {app: %{sts}}} spec: containers: [{name: nginx, image: nginx:1.26}] EOF $K -n %{ns} rollout status sts %{sts} --timeout=240s >/dev/null || exit 1 for i in 0 1; do j -n %{ns} pod %{sts}-$i '{.metadata.uid}'; echo; done > "$PRIV/q144.uids" """, "checks": [ C("updateStrategy RollingUpdate with partition 2", 2, r"""eq "$(j -n %{ns} sts %{sts} '{.spec.updateStrategy.type} {.spec.updateStrategy.rollingUpdate.partition}')" "RollingUpdate 2" """), C("%{sts}-2 runs nginx:1.27 and is ready", 3, r""" eq "$(j -n %{ns} sts %{sts} '{.spec.template.spec.containers[0].image}')" nginx:1.27 || exit 1 eq "$(j -n %{ns} pod %{sts}-2 '{.spec.containers[0].image} {.status.containerStatuses[0].ready}')" "nginx:1.27 true" """), C("%{sts}-0 and %{sts}-1 still run nginx:1.26, never replaced", 2, r""" eq "$(j -n %{ns} pod %{sts}-0 '{.spec.containers[0].image}') $(j -n %{ns} pod %{sts}-1 '{.spec.containers[0].image}')" "nginx:1.26 nginx:1.26" || exit 1 eq "$(for i in 0 1; do j -n %{ns} pod %{sts}-$i '{.metadata.uid}'; echo; done)" "$(cat "$PRIV/q144.uids")" \ && eq "$(j -n %{ns} sts %{sts} '{.spec.template.spec.containers[0].image}')" nginx:1.27"""), ], "solution": """ k -n %{ns} patch sts %{sts} -p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":2}}}}' k -n %{ns} set image sts/%{sts} nginx=nginx:1.27 k -n %{ns} rollout status sts %{sts} k -n %{ns} get pods -l app=%{sts} -o custom-columns=NAME:.metadata.name,IMAGE:.spec.containers[0].image # The partition FIRST: a StatefulSet updates from the highest ordinal down, and with no partition # the new image would reach -1 and -0 within moments. Pods with an ordinal >= partition get the new # template; the others keep theirs. Next step of the canary: partition 1, then 0. """, }, # ------------------------------------------------------------------ 145 ---- { "id": 145, "domain": "ARCH", "workers": 1, "title": "An operator that cannot finish its job", "params": lambda r: {"ns": ns_pick(r, "reports"), "name": name_pick(r, "daily")}, "task": """ The manifests of a backup operator are in `{WS}/q145/`: its CustomResourceDefinition, its RBAC and its controller. 1. Install the operator. 2. Create Namespace `%{ns}` and, in it, Backup `%{name}` with the schedule `30 1 * * *`. 3. The operator should create ConfigMap `backup-%{name}` for it and set its `status.phase` to `Scheduled`. Make sure it does - its manifests are not quite right. Change only what is wrong. """, "setup": r""" rm -rf "$WS/q145"; mkdir -p "$WS/q145" cat > "$WS/q145/operator.yaml" <<'EOF' """ + backup_operator(crd=True, status_rbac=False) + r"""EOF $K delete crd backups.data.example.com --ignore-not-found >/dev/null 2>&1 $K delete ns backup-operator --ignore-not-found >/dev/null 2>&1 """, "checks": [ C("the CRD is established and the controller runs", 2, r""" eq "$(j crd backups.data.example.com '{.status.conditions[?(@.type=="Established")].status}')" True && eq "$(j -n backup-operator deploy controller '{.status.readyReplicas}')" 1"""), C("Backup %{name} in %{ns} has the schedule", 1, r"""eq "$(j -n %{ns} backup %{name} '{.spec.schedule}')" "30 1 * * *" """), C("the operator made ConfigMap backup-%{name}", 1, r"""eq "$(j -n %{ns} cm backup-%{name} '{.data.schedule}')" "30 1 * * *" """), C("Backup %{name} is Scheduled: the controller may update its status", 3, r""" eq "$(j -n %{ns} backup %{name} '{.status.phase}')" Scheduled && eq "$(j clusterrolebinding backup-operator '{.subjects[0].name}')" controller"""), ], "solution": """ grep -E '^kind:|^ name:' {WS}/q145/operator.yaml k apply -f {WS}/q145/operator.yaml k wait --for condition=established crd/backups.data.example.com k -n backup-operator rollout status deploy controller k create ns %{ns} k -n %{ns} apply -f - <<'EOF' apiVersion: data.example.com/v1 kind: Backup metadata: name: %{name} spec: schedule: "30 1 * * *" EOF sleep 10 k -n %{ns} get cm backup-%{name} # made - but: k -n %{ns} get backup %{name} -o jsonpath='{.status}'; echo # no phase k -n backup-operator logs deploy/controller | tail -3 # ... cannot patch resource "backups/status" ... k auth can-i patch backups.data.example.com --subresource=status --as=system:serviceaccount:backup-operator:controller -A k patch clusterrole backup-operator --type json -p '[{"op":"add","path":"/rules/0/resources/-","value":"backups/status"}]' sleep 10 k -n %{ns} get backup %{name} -o jsonpath='{.status.phase}'; echo # The CRD has a status subresource, and a subresource is a resource of its own for RBAC: # `backups/status`. The controller could read Backups and write ConfigMaps, but not set a phase. # The log of an operator's controller is where such a gap shows - here as `forbidden`. """, }, # ------------------------------------------------------------------ 146 ---- { "id": 146, "domain": "STOR", "workers": 1, "title": "Grow a claim that is in use", "params": lambda r: {"ns": ns_pick(r, "media"), "size": r.choice(["70Mi", "80Mi", "100Mi"])}, "task": """ In Namespace `%{ns}`: 1. Create PersistentVolumeClaim `pv-volume`: class `csi-hostpath-sc`, access mode `ReadWriteOnce`, capacity `10Mi`. 2. Create Pod `web-server` (image `nginx:1.27`) that mounts the claim at `/usr/share/nginx/html`. 3. The site needs more room: expand the claim to `%{size}` while `web-server` keeps running. """, "setup": NS + r""" ensure_csi_hostpath || { echo "could not install the CSI hostpath driver (no network?) - the task needs it" >&2; exit 1; } mkns %{ns} $K -n %{ns} delete pod web-server --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1 $K -n %{ns} delete pvc pv-volume --ignore-not-found --wait=true >/dev/null 2>&1 """, "checks": [ C("pv-volume uses csi-hostpath-sc, ReadWriteOnce, and is Bound", 1, r""" eq "$(j -n %{ns} pvc pv-volume '{.spec.storageClassName} {.spec.accessModes[0]} {.status.phase}')" "csi-hostpath-sc ReadWriteOnce Bound" """), C("web-server runs nginx with the claim at /usr/share/nginx/html", 2, r""" eq "$(j -n %{ns} pod web-server '{.status.phase} {.spec.containers[0].image}')" "Running nginx:1.27" || exit 1 v=$(j -n %{ns} pod web-server '{.spec.containers[0].volumeMounts[?(@.mountPath=="/usr/share/nginx/html")].name}'); [ -n "$v" ] || exit 1 eq "$(j -n %{ns} pod web-server "{.spec.volumes[?(@.name==\"$v\")].persistentVolumeClaim.claimName}")" pv-volume"""), C("the claim has grown to %{size}, and the Pod kept running", 3, r""" eq "$(j -n %{ns} pvc pv-volume '{.spec.resources.requests.storage} {.status.capacity.storage}')" "%{size} %{size}" || exit 1 eq "$(j -n %{ns} pod web-server '{.status.phase} {.status.containerStatuses[0].restartCount}')" "Running 0" """), ], "solution": """ k get sc csi-hostpath-sc # ALLOWVOLUMEEXPANSION: true - a claim of this class may grow k -n %{ns} apply -f - <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: pv-volume spec: storageClassName: csi-hostpath-sc accessModes: [ReadWriteOnce] resources: requests: {storage: 10Mi} --- apiVersion: v1 kind: Pod metadata: name: web-server spec: containers: - name: web-server image: nginx:1.27 volumeMounts: - {name: html, mountPath: /usr/share/nginx/html} volumes: - name: html persistentVolumeClaim: {claimName: pv-volume} EOF k -n %{ns} wait --for=condition=Ready pod/web-server k -n %{ns} patch pvc pv-volume -p '{"spec":{"resources":{"requests":{"storage":"%{size}"}}}}' k -n %{ns} get pvc pv-volume # CAPACITY follows once the driver has resized it # `k edit pvc pv-volume` works just as well. A claim only grows, and only when its class has # allowVolumeExpansion: true and its driver can expand; spec.resources.requests is what you ask # for, status.capacity what you have. Filesystem volumes of this driver grow online - the Pod # keeps running. """, }, # ------------------------------------------------------------------ 147 ---- { "id": 147, "domain": "STOR", "workers": 1, "title": "Snapshot, restore, clone", "params": lambda r: {"ns": ns_pick(r, "ledger"), "n": r.randint(100, 999)}, "task": """ Claim `ledger-data` in `%{ns}` (class `csi-hostpath-sc`, `50Mi`) holds `ledger.txt`, written by Pod `writer`. 1. Take a VolumeSnapshot `ledger-snap` of the claim, with snapshot class `csi-hostpath-snapclass`, and wait until it is ready to use. 2. Create claim `ledger-restore` (`50Mi`, same class) **from that snapshot**. 3. Create claim `ledger-copy` (`50Mi`, same class) as a **clone of `ledger-data`** itself. 4. Run Pod `auditor` (image `busybox:1.36`, command `sleep 86400`) that mounts `ledger-restore` at `/restore` and `ledger-copy` at `/copy`. """, "setup": NS + r""" ensure_csi_hostpath || { echo "could not install the CSI hostpath driver (no network?) - the task needs it" >&2; exit 1; } mkns %{ns} $K -n %{ns} apply -f - >/dev/null <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: {name: ledger-data} spec: {storageClassName: csi-hostpath-sc, accessModes: [ReadWriteOnce], resources: {requests: {storage: 50Mi}}} --- apiVersion: v1 kind: Pod metadata: {name: writer} spec: containers: - {name: writer, image: busybox:1.36, command: [sh, -c, 'echo entries-%{n} > /data/ledger.txt; sync; sleep 86400'], volumeMounts: [{name: d, mountPath: /data}]} volumes: [{name: d, persistentVolumeClaim: {claimName: ledger-data}}] EOF $K -n %{ns} wait --for=condition=Ready pod/writer --timeout=180s >/dev/null """, "checks": [ C("VolumeSnapshot ledger-snap of ledger-data is ready to use", 2, r""" eq "$(j -n %{ns} volumesnapshot ledger-snap '{.spec.source.persistentVolumeClaimName} {.spec.volumeSnapshotClassName} {.status.readyToUse}')" "ledger-data csi-hostpath-snapclass true" """), C("ledger-restore is Bound, made from the snapshot", 2, r""" eq "$(j -n %{ns} pvc ledger-restore '{.spec.dataSource.kind}/{.spec.dataSource.name} {.status.phase}')" "VolumeSnapshot/ledger-snap Bound" """), C("ledger-copy is Bound, a clone of ledger-data", 2, r""" eq "$(j -n %{ns} pvc ledger-copy '{.spec.dataSource.kind}/{.spec.dataSource.name} {.status.phase}')" "PersistentVolumeClaim/ledger-data Bound" """), C("auditor reads the ledger from both", 2, r""" x() { $K -n %{ns} exec auditor -- cat "$1/ledger.txt" 2>/dev/null; } eq "$(x /restore)" entries-%{n} && eq "$(x /copy)" entries-%{n} \ && eq "$(j -n %{ns} pod auditor '{.spec.volumes[*].persistentVolumeClaim.claimName}' | tr ' ' '\n' | sort | tr '\n' ' ')" "ledger-copy ledger-restore " """), ], "solution": """ k get volumesnapshotclass # csi-hostpath-snapclass, driver hostpath.csi.k8s.io k -n %{ns} apply -f - <<'EOF' apiVersion: snapshot.storage.k8s.io/v1 kind: VolumeSnapshot metadata: name: ledger-snap spec: volumeSnapshotClassName: csi-hostpath-snapclass source: persistentVolumeClaimName: ledger-data EOF k -n %{ns} wait --for=jsonpath='{.status.readyToUse}'=true volumesnapshot/ledger-snap --timeout=120s k -n %{ns} apply -f - <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: name: ledger-restore spec: storageClassName: csi-hostpath-sc accessModes: [ReadWriteOnce] resources: requests: {storage: 50Mi} dataSource: apiGroup: snapshot.storage.k8s.io kind: VolumeSnapshot name: ledger-snap --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: ledger-copy spec: storageClassName: csi-hostpath-sc accessModes: [ReadWriteOnce] resources: requests: {storage: 50Mi} dataSource: kind: PersistentVolumeClaim name: ledger-data --- apiVersion: v1 kind: Pod metadata: name: auditor spec: containers: - name: auditor image: busybox:1.36 command: [sleep, '86400'] volumeMounts: - {name: restore, mountPath: /restore} - {name: copy, mountPath: /copy} volumes: - name: restore persistentVolumeClaim: {claimName: ledger-restore} - name: copy persistentVolumeClaim: {claimName: ledger-copy} EOF k -n %{ns} wait --for=condition=Ready pod/auditor --timeout=180s k -n %{ns} exec auditor -- cat /restore/ledger.txt /copy/ledger.txt # A snapshot is taken by the CSI driver (VolumeSnapshotClass -> driver); `dataSource` makes a new # claim from it - apiGroup snapshot.storage.k8s.io - or from another claim of the same class and # Namespace, a clone (no apiGroup: claims are core). Both need a CSI driver that can do it, and # the new claim must be at least as big as the source. """, }, ]