Compare commits

..

No commits in common. "e07ee1beab41f828d33792b7011b9cfdc8af646d" and "5e3d593f7948400b1c48c6192eed569b80272429" have entirely different histories.

12 changed files with 59 additions and 6061 deletions

File diff suppressed because it is too large Load Diff

View File

@ -40,7 +40,7 @@ This bank mirrors the real CKA curriculum weights (25/15/20/10/30% across the fi
| Operator Namespace Creator | Cluster Architecture | *no equivalent here* — this bank doesn't cover writing controller logic (arguably CKAD/dev territory more than CKA) | | Operator Namespace Creator | Cluster Architecture | *no equivalent here* — this bank doesn't cover writing controller logic (arguably CKAD/dev territory more than CKA) |
| Static Pod Move | Cluster Architecture | Q05, Q46 | | Static Pod Move | Cluster Architecture | Q05, Q46 |
| Cluster Setup and Node Join | Cluster Architecture | Q40 (token) + Q67 (independently verifying the CA discovery hash and real in-cluster API endpoint) — the token/crypto half only, see gap note | | Cluster Setup and Node Join | Cluster Architecture | Q40 (token) + Q67 (independently verifying the CA discovery hash and real in-cluster API endpoint) — the token/crypto half only, see gap note |
| Cluster Upgrade | Cluster Architecture | Q109 (the drain and uncordon around it) — the upgrade itself has no equivalent here, see gap note below | | Cluster Upgrade | Cluster Architecture | *no equivalent here* — see gap note below |
| Cluster Certificate Management | Cluster Architecture | Q06, Q65 | | Cluster Certificate Management | Cluster Architecture | Q06, Q65 |
### Real gaps worth knowing about ### Real gaps worth knowing about
@ -524,100 +524,6 @@ The exam-day skill this table is really pointing at, regardless of which platfor
--- ---
### Q107 · Kernel Prerequisites for a Kubernetes Node
- **Concept**: before `kubeadm` runs, every machine needs the `overlay` and `br_netfilter` kernel modules and three sysctls (`net.bridge.bridge-nf-call-iptables`, `net.bridge.bridge-nf-call-ip6tables`, `net.ipv4.ip_forward`) so that bridged Pod traffic passes through iptables and the node routes between Pods. `modprobe` and `sysctl -w` change the running kernel only; the files under `/etc/modules-load.d` and `/etc/sysctl.d` are what make the settings survive a reboot — which is what this task grades. Swap and the cgroup driver are the other two preflight classics: kubeadm refuses a node with active swap unless the kubelet says `failSwapOn: false`, and the kubelet's `cgroupDriver` must match the runtime's.
- **Solution**:
```bash
{SHELL_W3}
printf 'overlay\nbr_netfilter\n' > /etc/modules-load.d/k8s.conf
cat > /etc/sysctl.d/k8s.conf <<EOF
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
# on a real machine: modprobe overlay && modprobe br_netfilter && sysctl --system
sysctl -n net.ipv4.ip_forward
cat /proc/swaps # any line under the header = swap is on
grep -E 'failSwapOn|cgroupDriver' /var/lib/kubelet/config.yaml
```
- **Gotchas**: use the file name the task gives (it is drawn per session). The simulator's nodes are containers, so `modprobe br_netfilter` cannot work here and `/proc/sys/net/bridge` does not exist — write the files and move on; on the exam you would also apply them. `/proc/swaps` inside a node container shows the host's swap, which is why the answer here may be `on`.
---
### Q108 · The Container Runtime Behind a Pod
- **Concept**: the kubelet does not run containers itself — it drives a runtime (containerd here) through the CRI socket named by `containerRuntimeEndpoint` in `/var/lib/kubelet/config.yaml`. `crictl` speaks that same protocol, so it shows the node's view of a Pod: the sandbox (`crictl pods`), its containers (`crictl ps`), and everything about one container (`crictl inspect`: PID, log path, mounts). It keeps working when the API server is down, which is what makes it the tool for control-plane troubleshooting.
- **Solution**:
```bash
{SHELL_W2}
crictl version # RuntimeName / RuntimeVersion
grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml
crictl ps --no-trunc --name app --pod $(crictl pods --name <pod> -q)
crictl inspect <id> | grep -E '"pid"|logPath'
```
- **Gotchas**: `crictl ps` truncates IDs to 13 characters — `--no-trunc` (or `kubectl get pod -o jsonpath='{.status.containerStatuses[0].containerID}'`, minus the `containerd://` prefix) gives the full one. The PID is the process on the *node*; inside the container that process is PID 1. The log path under `/var/log/pods/<namespace>_<pod>_<uid>/` is the file `kubectl logs` reads.
---
### Q109 · Drain the Control-Plane Node and Bring It Back
- **Concept**: `kubectl drain` is cordon plus eviction — the first half of every node upgrade or maintenance window. It leaves DaemonSet Pods (they would be recreated at once) and static Pods (the kubelet owns them), and it refuses to go on when it meets emptyDir data or Pods without a controller until you say so explicitly. The node then stays `SchedulingDisabled` until `kubectl uncordon`. A Pod that can only run on the drained node (here: a nodeSelector for the control plane) sits in `Pending` for the duration.
- **Solution**:
```bash
kubectl drain {CP} --ignore-daemonsets --delete-emptydir-data
kubectl get nodes > {WS}/q109/drained.txt
kubectl -n <ns> get pod -l app=<deployment> # Pending
echo Pending > {WS}/q109/phase.txt
kubectl uncordon {CP}
kubectl -n <ns> rollout status deploy <deployment>
```
- **Gotchas**: `--force` is only for bare Pods and deletes them for good — do not add it by reflex. CoreDNS usually sits on the control-plane node in a fresh kubeadm cluster, so the drain moves it to the workers; it does not move back after the uncordon, and that is fine. Save `kubectl get nodes` *before* you uncordon — the file is the proof the node was drained.
---
### Q110 · Prepare the Join of a Second Control-Plane Node
- **Concept**: a joining control-plane node needs the cluster's CA, front-proxy CA, etcd CA and service-account keys. `kubeadm init phase upload-certs --upload-certs` encrypts them with a one-off *certificate key* and stores them for two hours in the Secret `kubeadm-certs`; `kubeadm join --control-plane --certificate-key <key>` downloads and decrypts them. All of this presupposes a `controlPlaneEndpoint` — the one stable address (a load balancer or VIP in production) that every node and kubeconfig uses instead of one particular API server.
- **Solution**:
```bash
kubectl -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint
{SHELL_CP}
kubeadm init phase upload-certs --upload-certs # last line: the certificate key
kubeadm token create --print-join-command --certificate-key <key>
```
- **Gotchas**: without `--certificate-key` (or `--control-plane`) `--print-join-command` prints a *worker's* join line. A key made with `kubeadm certs certificate-key` alone is just a random key — it only works if the certificates were uploaded *with* it (`upload-certs --certificate-key <key>`), so the simplest route is to let `upload-certs` generate it and copy what it prints.
---
### Q111 · Who Leads, and Who Is in etcd
- **Concept**: in a highly-available control plane every kube-apiserver serves at once, but only one kube-scheduler and one kube-controller-manager act — they elect a leader through a `Lease` in `kube-system`, and the holder identity is `<node>_<uuid>`. etcd elects its own leader and needs a majority of members to accept writes (3 members survive the loss of 1, 5 of 2). *Stacked* means an etcd member runs on each control-plane node, *external* that etcd has hosts of its own.
- **Solution**:
```bash
kubectl get nodes -l node-role.kubernetes.io/control-plane
kubectl -n kube-system get pods -l component=etcd -o wide
kubectl -n kube-system get lease kube-scheduler kube-controller-manager
{SHELL_CP}
export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379 ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt \
ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key
etcdctl member list -w table
etcdctl endpoint status -w table # IS LEADER
```
- **Gotchas**: write the *node* name for the two leases, not the whole holder identity — the uuid changes with every restart of the component. `ETCDCTL_API=3` is not needed any more; etcdctl 3.6 only warns about it. `endpoint status --cluster` asks every member through its advertised client URL, which fails when a node's address has changed; without `--cluster` you ask the local member, whose answer already names the leader.
---
### Q112 · CNI, CSI and CRI: What This Cluster Plugs In
- **Concept**: three interfaces keep networking, storage and container execution out of Kubernetes itself. **CRI**: the kubelet's socket to the runtime. **CNI**: for every Pod sandbox the runtime runs the plugin chain of the first configuration file in `/etc/cni/net.d` (binaries in `/opt/cni/bin`) — here `ptp` wires the veth pair, its `host-local` IPAM plugin hands out an address from the node's Pod CIDR, and `portmap` serves hostPorts. **CSI**: a storage driver registers a `CSIDriver` object and a StorageClass names it as `provisioner`.
- **Solution**:
```bash
{SHELL_W1}
ls /etc/cni/net.d/ && cat /etc/cni/net.d/*.conflist
grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml
exit
kubectl get csidrivers
kubectl get sc
```
- **Gotchas**: `cni-plugins` asks for the `type` of each plugin in the chain, in order — not the network's `name` (`kindnet`) and not the IPAM plugin, which has its own line. "No resources found" for CSIDrivers is the answer `0`: the local-path provisioner of this cluster is a plain external provisioner, not a CSI driver.
---
## Domain 2: Workloads & Scheduling (15%) ## Domain 2: Workloads & Scheduling (15%)
### Q07 · Rolling Update Strategy & Scaling ### Q07 · Rolling Update Strategy & Scaling
@ -1033,38 +939,6 @@ The exam-day skill this table is really pointing at, regardless of which platfor
--- ---
### Q105 · Autoscale on CPU, Scale Down Calmly
- **Concept**: a HorizontalPodAutoscaler compares a metric with a target and sets the replica count between `minReplicas` and `maxReplicas`. CPU *utilisation* is usage divided by the container's CPU **request**, so a container without one can never be scaled on it. `behavior` shapes the change: `scaleDown.stabilizationWindowSeconds` makes the HPA act on the highest recommendation of that window (no flapping), and `policies` cap how fast replicas go — here one Pod per minute.
- **Solution**:
```bash
kubectl -n <ns> set resources deploy <dep> -c web --requests=cpu=100m
kubectl -n <ns> autoscale deploy <dep> --min=<min> --max=<max> --cpu=<cpu>% # older kubectl: --cpu-percent=<cpu>
kubectl -n <ns> edit hpa <dep>
# spec:
# behavior:
# scaleDown:
# stabilizationWindowSeconds: <win>
# policies: [{type: Pods, value: 1, periodSeconds: 60}]
kubectl -n <ns> get hpa <dep>
```
- **Gotchas**: `kubectl autoscale` cannot write `behavior` — edit the object afterwards or write the `autoscaling/v2` YAML from the start. `TARGETS` shows `<unknown>` until metrics-server delivers data (it may not be installed here), but `minReplicas` is enforced immediately either way — which is what the last check looks at. `set resources` without `-c` changes every container; with it, only `web`.
---
### Q106 · Health Checks That Check the Wrong Thing
- **Concept**: the kubelet acts on probes, not on the application: a failing **liveness** probe gets the container killed and restarted, a failing **readiness** probe keeps the Pod out of every Service's endpoints without restarting it. So an application that is perfectly healthy looks broken when its probes point at the wrong port or path. The event text tells the two apart: `connection refused` is a port nobody listens on, `HTTP probe failed with statuscode: 404` is a path the server does not have.
- **Solution**:
```bash
kubectl -n <ns> describe pod -l app=<dep> --show-events | grep -E 'Liveness|Readiness|Unhealthy|Killing'
echo Unhealthy > {WS}/q106/reason.txt
kubectl -n <ns> get cm <dep>-conf -o yaml # listen <port>; the liveness and readiness locations
kubectl -n <ns> edit deploy <dep> # livenessProbe port, readinessProbe path
kubectl -n <ns> rollout status deploy <dep>
```
- **Gotchas**: `--show-events` costs nothing on the CKA's kubectl 1.35, which shows Events anyway, and is required from kubectl 1.36 on, where `describe` with a selector that matches several Pods leaves them out. Keep the probes HTTP and keep their timings — the graders compare them. Fixing only the readiness path produces Pods that turn Ready and are killed ten seconds later, so wait and look at `RESTARTS` before you move on.
---
## Domain 3: Services & Networking (20%) ## Domain 3: Services & Networking (20%)
### Q11 · NetworkPolicy (Ingress Restriction) ### Q11 · NetworkPolicy (Ingress Restriction)
@ -1363,20 +1237,6 @@ The exam-day skill this table is really pointing at, regardless of which platfor
--- ---
### Q113 · Pod to Pod, Across Nodes
- **Concept**: the Kubernetes network model in one task — every Pod has its own address, taken from its node's Pod CIDR (`.spec.podCIDR`), and any Pod reaches any other Pod on any node at that address without NAT; the CNI plugin is what makes it true. A Service is not involved at all. The exception is a `hostNetwork: true` Pod, which lives in the node's own network namespace: its address is the node's, outside every Pod CIDR, and it shares the node's ports.
- **Solution**:
```bash
kubectl -n <ns> get pods -o wide
kubectl -n <ns> get pod <web> -o jsonpath='{.status.podIP} {.spec.nodeName}' > {WS}/q113/web.txt
kubectl -n <ns> exec <client> -- wget -qO- -T3 http://<web ip> > {WS}/q113/direct.html
kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name} {.spec.podCIDR}{"\n"}{end}' > {WS}/q113/pod-cidrs.txt
echo <edge pod> > {WS}/q113/outside.txt; echo hostNetwork > {WS}/q113/why.txt
```
- **Gotchas**: `pod-cidrs.txt` wants *every* node, the control plane included. Compare the odd Pod's IP with `kubectl get nodes -o wide` and you see it is its node's INTERNAL-IP. Pod addresses change whenever a Pod is replaced, which is the whole reason Services exist — never hard-code one outside an exercise like this.
---
## Domain 4: Storage (10%) ## Domain 4: Storage (10%)
### Q15 · PersistentVolume, PVC & Pod Mounting ### Q15 · PersistentVolume, PVC & Pod Mounting
@ -1785,30 +1645,3 @@ The exam-day skill this table is really pointing at, regardless of which platfor
kubectl -n kube-system get pods -l component=kube-apiserver kubectl -n kube-system get pods -l component=kube-apiserver
``` ```
- **Gotchas**: don't reach for `kubectl describe`/`logs` first here — they need the very component that's down, so they'll just hang or refuse to connect. `crictl` talks to containerd directly on the node and works regardless of API server state, making it (along with `journalctl -u kubelet`) your only real diagnostic tool until the fix lands. Once fixed, double-check the *other* static pods (etcd, scheduler, controller-manager) are still healthy — it's easy to nick something else while editing manifests under pressure with no `kubectl` to sanity-check against. - **Gotchas**: don't reach for `kubectl describe`/`logs` first here — they need the very component that's down, so they'll just hang or refuse to connect. `crictl` talks to containerd directly on the node and works regardless of API server state, making it (along with `journalctl -u kubelet`) your only real diagnostic tool until the fix lands. Once fixed, double-check the *other* static pods (etcd, scheduler, controller-manager) are still healthy — it's easy to nick something else while editing manifests under pressure with no `kubectl` to sanity-check against.
---
### Q103 · Find the Heaviest Pods with `kubectl top`
- **Concept**: `kubectl top` reads the metrics API (`metrics.k8s.io`), which metrics-server serves from what each kubelet reports. It shows *usage* — not requests or limits, which are in `kubectl describe node` under "Allocated resources". `--sort-by=cpu|memory` puts the largest first; `--containers` splits a Pod into its containers; `-A` covers every Namespace.
- **Solution**:
```bash
kubectl top pod -n <ns> --sort-by=cpu --no-headers | head -1 | awk '{print $1}' > {WS}/q103/cpu.txt
kubectl top pod -n <ns> --sort-by=memory --no-headers | head -1 | awk '{print $1}' > {WS}/q103/memory.txt
echo 'kubectl top node' > {WS}/q103/nodes.sh
echo 'kubectl top pod -A --sort-by=memory' > {WS}/q103/pods.sh
```
- **Gotchas**: give metrics-server about a minute after the Pods start — until then `top` says `metrics not available yet` for them. `Metrics API not available` means metrics-server itself is missing or not Ready (that is Q23's scenario, which is why the two never share a session). The scripts run in a plain shell: spell out `kubectl`, an alias `k` does not exist there.
---
### Q104 · One Replica Is Logging Errors
- **Concept**: `kubectl logs` reads one container's stdout/stderr. With `-l` it reads every Pod of a label at once — but then shows only the last 10 lines per Pod unless `--tail` says otherwise, and you cannot tell the Pods apart without `--prefix`. An init container's log is read like any other container's, with `-c`, long after it has finished.
- **Solution**:
```bash
kubectl -n <ns> logs -l app=<app> --prefix --tail=-1 | grep ERROR | head -3 # [pod/<app>-N/router] ERROR ...
echo <app>-N > {WS}/q104/bad-pod.txt
kubectl -n <ns> logs <app>-N | grep ERROR > {WS}/q104/errors.log
kubectl -n <ns> logs <app>-0 -c migrate # schema version ... is current
echo "kubectl -n <ns> logs -l app=<app> --prefix --tail=2" > {WS}/q104/tail.sh
```
- **Gotchas**: `errors.log` must hold the ERROR lines and nothing else — redirecting the whole log fails the check. `kubectl logs statefulset/<app>` (or `deploy/...`) picks *one* Pod; only a selector covers them all. `--tail=2` with `--prefix` gives two lines per Pod, each starting `[pod/<name>/<container>]`.

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,11 @@ Entry point is `./exam` (renamed from `cka-sim` so it can't collide with the dir
Hands-on exam tasks graded **per check** against a real cluster, an exam clock, a browser Hands-on exam tasks graded **per check** against a real cluster, an exam clock, a browser
front end modelled on the real PSI exam, attempt history, and drills that rotate through the front end modelled on the real PSI exam, attempt history, and drills that rotate through the
domains before you sit a full exam. Tracks: **CKA** (113 tasks: a random pool of 54 normal + domains before you sit a full exam. Tracks: **CKA** (102 tasks: a random pool of 43 normal +
24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), **CKAD** (44 pool tasks, plus 24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), **CKAD** (37 pool tasks, at
a 20-task killer.sh-style Set A) and CKS (a 9-task starter bank in the same format). Each track least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (a
runs on the Kubernetes version its real exam runs — CKA and CKS on **v1.35**, CKAD on **v1.37** 9-task starter bank in the same format). Each track runs on the Kubernetes version its real exam
(see [Clusters and versions](#clusters-and-versions)). runs — CKA and CKS on **v1.35**, CKAD on **v1.37** (see [Clusters and versions](#clusters-and-versions)).
CKA and CKAD are also laid out as a **study path**: the curriculum objective by objective, each
with notes, at least two pool tasks, and tracked progress from first reading to exam conditions
(see [Study path](#study-path-the-curriculum-objective-by-objective)).
## How a session is built ## How a session is built
@ -108,10 +104,7 @@ The same state is available from the terminal if you prefer:
./exam drill weak | drill 2 17 20 | drill TSHOOT NET | drill setd ./exam drill weak | drill 2 17 20 | drill TSHOOT NET | drill setd
./exam start [--hard] # full exam (gated until all domains drilled; --force) ./exam start [--hard] # full exam (gated until all domains drilled; --force)
./exam sim c|d # a fixed 18/17-task simulator set, exam rules, 120 min ./exam sim c|d # a fixed 18/17-task simulator set, exam rules, 120 min
./exam objectives # CKA/CKAD: the curriculum as a path, with your progress ./exam study [DOMAIN|weak|N..] # no clock, solutions visible, nothing recorded
./exam next # start whatever is next on that path
./exam study [OBJ|DOMAIN|weak|N..] # no clock, solutions visible, kept out of the history
./exam notes [OBJ|DOMAIN|all] # study notes of an objective (3.2 or probes)
./exam list | show N | timer | status # during ./exam list | show N | timer | status # during
./exam end # submit ./exam end # submit
./exam weak | history # afterwards ./exam weak | history # afterwards
@ -120,69 +113,21 @@ The same state is available from the terminal if you prefer:
./teardown.sh [cka|ckad|cks|legacy] # delete clusters + state (history kept; default all) ./teardown.sh [cka|ckad|cks|legacy] # delete clusters + state (history kept; default all)
``` ```
## Study path: the curriculum, objective by objective
`tracks/cka/curriculum.py` and `tracks/ckad/curriculum.py` hold the exam curricula in their own
words and order - 27 objectives for CKA, 24 for CKAD. Every objective has study notes (what to
know, the fastest commands, the traps, the documentation pages the exam lets you open) and lists
the tasks that practise it; every task of the bank is listed under at least one objective, and
every objective has at least two pool tasks.
```
./exam objectives # every objective, its tasks, where you are, and the next step
./exam next # start that next step
./exam notes 3.2 # the notes of one objective, by id or key (3.2, probes); `all`, or a domain
./exam study 3.2 # those notes, then its tasks: no clock, solutions open (--all adds simulator-set tasks)
./exam drill 3.2 # the same tasks against the clock, no solution until a task is graded
```
Each objective goes through two stages, and the path ends in the mocks:
1. **Study** - solve every task of the objective once, with the notes and the reference solutions
open. The objective is then *studied* (`~`).
2. **Clock** - pass the same tasks as a timed drill (6 minutes per task, new parameters). When the
latest attempt at every task is a full pass, the objective is *complete* (`✓`).
3. **Mock exam** (`./exam start`) once every objective is complete, then the fixed
**simulator sets** (`./exam sim a` for CKAD, `c` and `d` for CKA) - exam rules, no feedback
until you submit.
`./exam next` always starts the first open step in that order; `./exam objectives` shows the whole
ladder. An objective with many tasks takes several sittings: each brings up to five tasks you have
not solved yet. The CKA path walks the domains Workloads, Networking, Storage, Architecture and
then Troubleshooting - it is 30% of the exam and builds on all the others (`STUDY_ORDER` in its
curriculum.py); CKAD follows the curriculum's order. Nothing is locked: study or drill any
objective at any time.
Progress is tracked in two places. Timed attempts are in `history-<track>.jsonl`, as always, and
the objective map reads them - so what you have already drilled counts. Tasks solved in study mode
and the date each objective was first studied and first completed are in `study-<track>.json`,
next to the history (it survives `./teardown.sh` the same way); study results never reach the
statistics, the weak list or the exam gate. `./exam end` says which objectives a session got over
a line and what comes next.
The browser UI has the same thing in the lobby: the ladder, *Continue the path*, and *Study an
objective* (the full list with notes and progress). During a study session, and in a drill once a
task has been graded, the notes of the task's objective sit under its reference solution.
`CKA_STUDY_GUIDE.md` and `CKAD_STUDY_GUIDE.md` are the notes of every objective as one document,
generated with `./exam --track <track> notes all --md`.
## CKAD track ## CKAD track
`./exam --track ckad ...` (every command takes it), on its own Kubernetes v1.37 cluster `./exam --track ckad ...` (every command takes it), on its own Kubernetes v1.37 cluster
(`./setup.sh ckad`). A mock draws **16** tasks in 120 minutes, (`./setup.sh ckad`). A mock draws **16** tasks in 120 minutes,
weighted like the exam: Design and Build 3, Deployment 3, Observability 3, Environment and weighted like the exam: Design and Build 3, Deployment 3, Observability 3, Environment and
Security 4, Networking 3, from a pool of 44 (9/8/11/11/5), so no two mocks are the same set. Every Security 4, Networking 3, from a pool of 37 (8/7/7/10/5), so no two mocks are the same set. Every
line of the curriculum has at least two pool tasks: image builds, Jobs, DaemonSets, native sidecars, line of the curriculum has at least one task: image builds, Jobs, DaemonSets, native sidecars,
volumes; blue/green, canary, rolling-update limits, Helm, Kustomize; API deprecations, probes, volumes; blue/green, canary, rolling-update limits, Helm, Kustomize; API deprecations, probes,
`kubectl top`, logs, debugging (CrashLoopBackOff, OOMKilled, config errors); CRDs, RBAC, Pod `kubectl top`, logs, debugging (CrashLoopBackOff, OOMKilled, config errors); CRDs, RBAC, Pod
Security admission, quotas and LimitRanges, QoS, ConfigMaps, Secrets, ServiceAccounts; Security admission, quotas and LimitRanges, QoS, ConfigMaps, Secrets, ServiceAccounts;
NetworkPolicy (tested with real traffic), Services, Ingress with TLS. NetworkPolicy (tested with real traffic), Services, Ingress with TLS.
A few tasks need more than kubectl on this machine: `podman` (Q13, Q41 and Q60 build images), A few tasks need more than kubectl on this machine: `podman` (Q13 builds an image), `helm`
`helm` (Q6, Q21, Q43), and network access the first time Q23 runs on a fresh cluster (it installs (Q6, Q21), and network access the first time Q23 runs on a fresh cluster (it installs
metrics-server). Drill a domain with `./exam --track ckad drill ENV`, as for CKA, or an objective metrics-server). Drill a domain with `./exam --track ckad drill ENV`, as for CKA.
with `./exam --track ckad drill 4.3`.
**Simulator Set A** (`./exam --track ckad sim a`): 20 original killer.sh-style tasks, 140 points, **Simulator Set A** (`./exam --track ckad sim a`): 20 original killer.sh-style tasks, 140 points,
120 minutes, exam rules - a fixed set (ids 38-57) kept out of the random pool, so reruns are 120 minutes, exam rules - a fixed set (ids 38-57) kept out of the random pool, so reruns are
@ -234,13 +179,13 @@ etcd 3.6, as the exam does: `etcdutl` restores and inspects snapshots, `etcdctl`
## CKA bank ## CKA bank
| Domain | Normal (54) | Hard (24) | | Domain | Normal (43) | Hard (24) |
|---|---|---| |---|---|---|
| Architecture | RBAC, etcd snapshot, Kustomize, CRD, static pod, certs/CSR, Helm, aggregated ClusterRole, `kubectl explain` on a CRD, kubeadm join token, component versions, kernel prerequisites of a node, the runtime behind a Pod (`crictl`), draining the control-plane node, the join of a second control-plane node, leader leases + etcd members, CNI/CSI/CRI inventory | exact RBAC with an over-grant to remove, **etcd restore**, static pod on a relocated path, user → self-contained kubeconfig, Kustomize generators + patches, CRD validation/defaults, renew all control-plane certs | | Architecture | RBAC, etcd snapshot, Kustomize, CRD, static pod, certs/CSR, Helm, aggregated ClusterRole, `kubectl explain` on a CRD, kubeadm join token, component versions | exact RBAC with an over-grant to remove, **etcd restore**, static pod on a relocated path, user → self-contained kubeconfig, Kustomize generators + patches, CRD validation/defaults, renew all control-plane certs |
| Workloads | rolling update, sidecar, node affinity + resources, ConfigMap/Secret, metrics-server repair + HPA, PriorityClass, taints, DaemonSet, CronJob, init container, immutable ConfigMap rotation, parallel Job, quota + LimitRange, HPA with a scale-down policy, probes that check the wrong thing | anti-affinity + toleration + priority under a quota, rollback to a specific revision then pause, native sidecar + probes + exact report, cluster audit to exact files, work in the context's namespace | | Workloads | rolling update, sidecar, node affinity + resources, ConfigMap/Secret, metrics-server repair + HPA, PriorityClass, taints, DaemonSet, CronJob, init container, immutable ConfigMap rotation, parallel Job, quota + LimitRange | anti-affinity + toleration + priority under a quota, rollback to a specific revision then pause, native sidecar + probes + exact report, cluster audit to exact files, work in the context's namespace |
| Networking | NetworkPolicy, NodePort, Ingress, cluster DNS, Gateway API, named ports, egress-only policy, headless + ExternalName, Pod-to-Pod across nodes | zero-trust namespace (4 policies + trap), TLS Ingress with generated cert + fingerprint, Gateway canary with header match and weights, DNS down three ways | | Networking | NetworkPolicy, NodePort, Ingress, cluster DNS, Gateway API, named ports, egress-only policy, headless + ExternalName | zero-trust namespace (4 policies + trap), TLS Ingress with generated cert + fingerprint, Gateway canary with header match and weights, DNS down three ways |
| Storage | PV/PVC/pod, default StorageClass, re-bind a Released PV, data survives the pod | bind the right local volume by selector, migrate data between claims via a Job | | Storage | PV/PVC/pod, default StorageClass, re-bind a Released PV, data survives the pod | bind the right local volume by selector, migrate data between claims via a Job |
| Troubleshooting | node NotReady, broken deployment + service, CrashLoopBackOff, scheduler down, CoreDNS misconfig, controller-manager down, kubelet config drift, `kubectl top`, logs by selector and of an init container | worker with three faults (+ uncordon check), two control-plane components down, app down for four reasons fixed in place (UID check), cluster facts exactly | | Troubleshooting | node NotReady, broken deployment + service, CrashLoopBackOff, scheduler down, CoreDNS misconfig, controller-manager down, kubelet config drift | worker with three faults (+ uncordon check), two control-plane components down, app down for four reasons fixed in place (UID check), cluster facts exactly |
Disruptive tasks (anything that breaks a node or control-plane component) are seeded last and Disruptive tasks (anything that breaks a node or control-plane component) are seeded last and
can block other tasks until fixed — read everything first, as in the real exam. Tasks that damage can block other tasks until fixed — read everything first, as in the real exam. Tasks that damage
@ -260,10 +205,10 @@ were retired in favour of these two; their last versions are in git history.)
## Adding tasks or a new track ## Adding tasks or a new track
Banks live in `tracks/cka/{questions,bank_extra,bank_hard,bank_objectives,bank_sim_*}.py`; a task is: Banks live in `tracks/cka/{questions,bank_extra,bank_hard,bank_sim_*}.py`; a task is:
```python ```python
{"id": 114, "domain": "NET", "difficulty": "normal", # or "hard" - ids stay contiguous {"id": 103, "domain": "NET", "difficulty": "normal", # or "hard" - ids stay contiguous
"params": lambda r: {"ns": ns_pick(r, "svc"), "port": r.choice([8080, 9090])}, "params": lambda r: {"ns": ns_pick(r, "svc"), "port": r.choice([8080, 9090])},
"title": "...", "task": "...markdown; %{ns} and %{port} are substituted; `code` copies on click...", "title": "...", "task": "...markdown; %{ns} and %{port} are substituted; `code` copies on click...",
"setup": "bash that seeds the scenario (may use %{ns})", "setup": "bash that seeds the scenario (may use %{ns})",
@ -279,8 +224,3 @@ and helpers `j <get args> '{jsonpath}'` and `eq a b`. Task text may use `{WS}`,
session draws, `TRACK["k8s"]` the Kubernetes minor (and so the cluster) the track runs on, and session draws, `TRACK["k8s"]` the Kubernetes minor (and so the cluster) the track runs on, and
`TRACK["exam_contexts"]` whether it owns the `k8s-c*` contexts. For CKS, add to `QUESTIONS` in `TRACK["exam_contexts"]` whether it owns the `k8s-c*` contexts. For CKS, add to `QUESTIONS` in
its track; `TRACK` already carries the domain table and pass mark. its track; `TRACK` already carries the domain table and pass mark.
In a track with a `curriculum.py`, list a new task under the objective(s) it practises ("tasks").
`./exam objectives` warns about a task that no objective lists, a listed id that does not exist,
and an objective left with fewer than two pool tasks. A track without that file simply has no
objective commands (CKS today).

523
exam
View File

@ -6,12 +6,10 @@ import http.server
import importlib import importlib
import json import json
import os import os
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
import sys import sys
import textwrap
import threading import threading
import time import time
import urllib.parse import urllib.parse
@ -19,7 +17,7 @@ from collections.abc import Callable
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
VERSION = "2026.10.05.1" VERSION = "2026.09.07.1"
ROOT = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT) sys.path.insert(0, ROOT)
@ -40,9 +38,6 @@ PASS_PCT = 0
STATE_FILE = "" STATE_FILE = ""
HISTORY_FILE = "" HISTORY_FILE = ""
WEAK_FILE = "" WEAK_FILE = ""
STUDY_FILE = "" # tasks solved in study mode (the objective checklist reads it; history never does)
OBJECTIVES = [] # the track's curriculum, objective by objective (tracks/<id>/curriculum.py), if it has one
STUDY_ORDER = [] # domains in the order the path walks them, when that differs from the curriculum's own
K8S = "" # Kubernetes minor the track's real exam runs, e.g. "v1.35" K8S = "" # Kubernetes minor the track's real exam runs, e.g. "v1.35"
CLUSTER = "" # kind cluster for that minor CLUSTER = "" # kind cluster for that minor
CONTEXT = "" CONTEXT = ""
@ -70,14 +65,6 @@ def load_track(track_id):
sys.exit(f"unknown track '{track_id}' (no tracks/{track_id}/questions.py)") sys.exit(f"unknown track '{track_id}' (no tracks/{track_id}/questions.py)")
importlib.reload(mod) importlib.reload(mod)
track, questions = mod.TRACK, list(mod.QUESTIONS) track, questions = mod.TRACK, list(mod.QUESTIONS)
try:
cur = importlib.import_module(f"tracks.{track_id}.curriculum")
cur = importlib.reload(cur)
objectives, study_order = list(cur.OBJECTIVES), list(getattr(cur, "STUDY_ORDER", []))
except ModuleNotFoundError as e:
if e.name != f"tracks.{track_id}.curriculum":
raise
objectives, study_order = [], [] # no objective map for this track: study/drill by domain only
for q in questions: for q in questions:
q["weight"] = sum(c["pts"] for c in q["checks"]) q["weight"] = sum(c["pts"] for c in q["checks"])
q.setdefault("difficulty", "normal") q.setdefault("difficulty", "normal")
@ -94,7 +81,6 @@ def load_track(track_id):
STATE_FILE=os.path.join(ROOT, ".state", f"state-{track_id}.json"), STATE_FILE=os.path.join(ROOT, ".state", f"state-{track_id}.json"),
HISTORY_FILE=os.path.join(ROOT, f"history-{track_id}.jsonl"), HISTORY_FILE=os.path.join(ROOT, f"history-{track_id}.jsonl"),
WEAK_FILE=os.path.join(ROOT, f"weak-spots-{track_id}.md"), WEAK_FILE=os.path.join(ROOT, f"weak-spots-{track_id}.md"),
STUDY_FILE=os.path.join(ROOT, f"study-{track_id}.json"), OBJECTIVES=objectives, STUDY_ORDER=study_order,
# The CKA_* variables point every track at your own cluster instead (see the README). # The CKA_* variables point every track at your own cluster instead (see the README).
K8S=track["k8s"], CLUSTER=cluster, K8S=track["k8s"], CLUSTER=cluster,
CONTEXT=os.environ.get("CKA_CONTEXT", f"kind-{cluster}"), CONTEXT=os.environ.get("CKA_CONTEXT", f"kind-{cluster}"),
@ -653,367 +639,6 @@ def write_weak_spots():
with open(WEAK_FILE, "w") as f: with open(WEAK_FILE, "w") as f:
f.write("\n".join(L)) f.write("\n".join(L))
# --------------------------------------------------------- curriculum ----
# A track may ship tracks/<id>/curriculum.py: the exam's objectives in order, each with study
# notes and the tasks that practise it. It turns the bank into a path - study an objective with
# the solutions open, then prove it against the clock - and `./exam objectives` shows where you are.
def find_objective(arg):
"""An objective by id (3.2) or key (probes, or any unambiguous start of it); None if none fits."""
a = str(arg).lower()
for o in OBJECTIVES:
if a in (o["id"], o["key"]):
return o
hits = [o for o in OBJECTIVES if len(a) >= 3 and o["key"].startswith(a)]
return hits[0] if len(hits) == 1 else None
def objective_tasks(o, with_sets=False):
"""An objective's tasks in its own order. Tasks of the fixed simulator sets stay out unless
asked for: meeting them one objective at a time would spoil the mock they are kept for."""
return [QBYID[i] for i in o["tasks"] if i in QBYID and (with_sets or not QBYID[i].get("set"))]
def objectives_of(qid):
return [o for o in OBJECTIVES if qid in o["tasks"]]
def objective_domains():
"""Domain codes in the order the curriculum lists them (TRACK["domains"] may order them otherwise)."""
return list(dict.fromkeys(o["domain"] for o in OBJECTIVES))
def path_objectives():
"""Objectives in the order the path walks them: the curriculum's order, unless the track names
a STUDY_ORDER of domains (CKA leaves Troubleshooting for last - it builds on all the rest)."""
order = [d for d in STUDY_ORDER if d in DOMAINS] + [d for d in objective_domains() if d not in STUDY_ORDER]
return sorted(OBJECTIVES, key=lambda o: order.index(o["domain"])) # stable: curriculum order within a domain
def objective_session(o, how, with_sets=False, n=None):
"""The tasks of one sitting on an objective: what is still open first ("study": never solved;
"drill": not yet passed against the clock), at most n, never two that break the same thing.
A big objective therefore takes several sittings, each on new ground."""
agg, studied = stats(), load_study()
closed = ((lambda q: task_solved(q["id"], agg, studied)) if how == "study"
else (lambda q: task_state(q["id"], agg, studied) == "passed"))
qs = objective_tasks(o, with_sets)
todo = [q for q in qs if not closed(q)] or qs # nothing open: go over all of it again
return no_conflicts(todo, n or DRILL_SIZE), len(todo)
def load_progress():
"""study-<track>.json, next to the history so it survives ./teardown.sh as the history does:
tasks task id -> its latest result in a study session ("passed" sticks once earned)
objectives objective id -> when it was first studied and first completed
Study results are kept apart from the history on purpose: a pass with the solution open is
practice, not proof, so it never reaches the statistics, the weak list or the exam gate."""
try:
with open(STUDY_FILE) as f:
d = json.load(f)
except (OSError, ValueError):
d = {}
return {"tasks": {int(k): v for k, v in d.get("tasks", {}).items()}, "objectives": dict(d.get("objectives", {}))}
def save_progress(prog):
with open(STUDY_FILE, "w") as f:
json.dump({"tasks": {str(k): prog["tasks"][k] for k in sorted(prog["tasks"])},
"objectives": {k: prog["objectives"][k] for k in sorted(prog["objectives"])}}, f, indent=1)
f.write("\n")
def load_study():
return load_progress()["tasks"]
def record_study(st, ids=None):
"""Note what a study session graded (all of it, or just `ids`)."""
if not untimed(st):
return
prog = load_progress(); log = prog["tasks"]
for k, r in st.get("results", {}).items():
if ids is not None and int(k) not in ids:
continue
log[int(k)] = {"at": datetime.now().isoformat(timespec="minutes"), "pass": r["pass"], "earned": r["earned"],
"total": r["total"], "passed": bool(r["pass"] or log.get(int(k), {}).get("passed"))}
save_progress(prog)
def task_state(qid, agg, studied):
"""passed = the latest attempt against the clock was a full pass; failed = it was not;
studied = solved in study mode but never attempted against the clock; new = neither."""
a = agg.get(int(qid))
if a is not None and a.attempts:
return "passed" if a.last else "failed"
return "studied" if studied.get(int(qid), {}).get("passed") else "new"
def task_solved(qid, agg, studied):
"""Solved at least once, with the solution open or against the clock."""
a = agg.get(int(qid))
return bool(studied.get(int(qid), {}).get("passed") or (a is not None and a.attempts > a.fails))
MARKS = {"passed": ("✓", green), "studied": ("~", yellow), "failed": ("✗", red), "new": ("·", dim), "started": ("-", yellow)}
def mark(state):
ch, col = MARKS[state]
return col(ch)
def objective_state(o, agg, studied):
"""The two stages every objective goes through, and where it is:
studied every pool task solved at least once (study mode counts)
done every pool task passed against the clock on its latest attempt - exam conditions
before that: started (something attempted) or new."""
qs = objective_tasks(o)
states = [task_state(q["id"], agg, studied) for q in qs]
if qs and all(x == "passed" for x in states):
return "done"
if qs and all(task_solved(q["id"], agg, studied) for q in qs):
return "studied"
return "started" if any(x != "new" for x in states) else "new"
OBJECTIVE_MARK = {"done": "passed", "studied": "studied", "started": "started", "new": "new"}
def next_objective(agg=None, studied=None):
"""(objective, how) for the first objective in curriculum order that is not done: "study" it
until every task has been solved once, then "drill" it against the clock. (None, None) when
every objective is done."""
agg = stats() if agg is None else agg
studied = load_study() if studied is None else studied
for o in path_objectives():
state = objective_state(o, agg, studied)
if state != "done":
return o, ("drill" if state == "studied" else "study")
return None, None
def mock_scores():
"""Scores of the full mocks and of each fixed simulator set, from the history."""
full, fixed = [], {k: [] for k in SETS}
members = {k: {q["id"] for q in set_questions(k)} for k in SETS}
for e in load_history():
if e["mode"] != "exam":
continue
hit = next((k for k in SETS if set(e["ids"]) == members[k]), None)
(fixed[hit] if hit else full).append(e["score_pct"])
return full, fixed
def next_step(agg=None, studied=None):
"""The next move on the path to the exam, as {kind, cmd, why[, objective | set]}:
study an objective -> drill it against the clock -> ... every objective -> a full mock ->
the fixed simulator sets. Each stage is closer to the real exam than the one before."""
agg = stats() if agg is None else agg
studied = load_study() if studied is None else studied
o, how = next_objective(agg, studied)
if o and how == "study":
return {"kind": "study", "objective": o, "cmd": f"./exam study {o['id']}",
"why": f"{o['id']} {o['text']} - notes, then its tasks with no clock and the solutions open"}
if o:
return {"kind": "drill", "objective": o, "cmd": f"./exam drill {o['id']}",
"why": f"{o['id']} {o['text']} - studied; now the same tasks against the clock, no solution until graded"}
full, fixed = mock_scores()
if not any(x >= PASS_PCT for x in full):
return {"kind": "mock", "cmd": "./exam start",
"why": f"every objective is done - a full mock: {sum(QUOTA.values())} tasks, {TRACK['minutes']} minutes, no feedback until you submit"}
for k in SETS:
if not any(x >= PASS_PCT for x in fixed[k]):
return {"kind": "sim", "set": k, "cmd": f"./exam sim {k}",
"why": f"{SETS[k]}: {len(set_questions(k))} fixed tasks, {TRACK['minutes']} minutes, exam rules"}
return {"kind": "done", "cmd": "./exam start --hard",
"why": f"every objective done and every mock passed at {PASS_PCT}% or better - keep sharp with a fresh mock"}
def update_milestones():
"""Stamp each objective the first time it is studied and the first time it is done, and
return what was newly reached as [(objective, "studied" | "done")]. Called whenever results
are recorded, so the checklist keeps the date you got there."""
if not OBJECTIVES:
return []
prog = load_progress(); agg = stats(); new = []
now = datetime.now().isoformat(timespec="minutes")
for o in OBJECTIVES:
state = objective_state(o, agg, prog["tasks"])
seen = prog["objectives"].setdefault(o["id"], {})
for level in ("studied", "done"):
if state in (level, "done") and level not in seen:
seen[level] = now
new.append((o, level))
if not seen:
del prog["objectives"][o["id"]]
if new:
save_progress(prog)
# reaching "done" in one go also stamps "studied"; only the higher one is news
return [(o, lv) for o, lv in new if not (lv == "studied" and (o, "done") in new)]
def milestone_lines(new):
out = []
for o, level in new:
n = len(objective_tasks(o))
if level == "done":
out.append(f"✓ Objective {o['id']} {o['key']} complete - {n}/{n} tasks passed against the clock.")
else:
out.append(f"~ Objective {o['id']} {o['key']} studied - every task solved once. Prove it against the clock: ./exam drill {o['id']}")
return out
def curriculum_gaps():
"""What would make the path lie: a task no objective lists, a listed id that does not exist,
an objective too thin to study."""
listed = {i for o in OBJECTIVES for i in o["tasks"]}
out = []
orphans = [q["id"] for q in QUESTIONS if q["id"] not in listed]
if orphans:
out.append(f"not listed under any objective: Q{', Q'.join(map(str, orphans))}")
ghosts = sorted(listed - set(QBYID))
if ghosts:
out.append(f"listed but not in the bank: Q{', Q'.join(map(str, ghosts))}")
thin = [o["id"] for o in OBJECTIVES if len(objective_tasks(o)) < 2]
if thin:
out.append(f"fewer than two pool tasks: {', '.join(thin)}")
wrong = [f"{o['id']} ({o['domain']})" for o in OBJECTIVES if o["domain"] not in DOMAINS]
if wrong:
out.append(f"unknown domain: {', '.join(wrong)}")
return out
def require_objectives():
if not OBJECTIVES:
die(f"Track '{TRACK_ID}' has no objective map (tracks/{TRACK_ID}/curriculum.py).\n"
f" Study it by domain instead: ./exam study {next(iter(DOMAINS))}")
def cmd_objectives(args):
"""The curriculum as a checklist: every objective, its tasks, and how far you are."""
require_objectives()
agg, studied = stats(), load_study()
cols = shutil.get_terminal_size((120, 24)).columns
sets = len(QUESTIONS) - len(POOL)
print(bold(f"{TRACK['name']} - study path") + dim(f" {len(OBJECTIVES)} objectives · {len(POOL)} tasks"
+ (f" · {sets} more in the fixed sets [in brackets]" if sets else "")))
print(dim("tasks: ✓ passed against the clock ~ solved in study mode ✗ failed last time · not yet"
" objectives: ✓ complete ~ studied - started"))
def cell(q):
return f"{MARKS[task_state(q['id'], agg, studied)][0]}{q['id']}", f"{mark(task_state(q['id'], agg, studied))}{q['id']}"
rows = []
for o in OBJECTIVES:
pool = [cell(q) for q in objective_tasks(o)]
fixed = [cell(q) for q in objective_tasks(o, True) if q.get("set")]
plain = " ".join(c[0] for c in pool) + (" [" + " ".join(c[0] for c in fixed) + "]" if fixed else "")
shown = " ".join(c[1] for c in pool) + (dim(" [") + " ".join(c[1] for c in fixed) + dim("]") if fixed else "")
rows.append((o, plain, shown))
tw = max(28, min(max(len(o["text"]) for o in OBJECTIVES), cols - 26 - max(len(r[1]) for r in rows)))
for d in objective_domains():
print(bold(f"\n{DOMAINS[d]}"))
for o, plain, shown in rows:
if o["domain"] != d:
continue
state = objective_state(o, agg, studied)
text = o["text"] if len(o["text"]) <= tw else o["text"][:tw - 1] + "…"
print(f" {mark(OBJECTIVE_MARK[state])} {o['id']:<4}{o['key']:<16}{text:<{tw}} {shown}")
print_path(agg, studied)
for g in curriculum_gaps():
print(yellow(f"curriculum.py: {g}"))
def print_path(agg=None, studied=None):
"""The ladder from first reading to exam conditions, how far up you are, and the next rung."""
agg = stats() if agg is None else agg
studied = load_study() if studied is None else studied
states = [objective_state(o, agg, studied) for o in OBJECTIVES]
n, done = len(OBJECTIVES), states.count("done")
full, fixed = mock_scores()
step = next_step(agg, studied)
def best(scores):
if not scores:
return dim("not sat yet")
b = max(scores)
return (green if b >= PASS_PCT else red)(f"best {b}%") + dim(f" of {len(scores)}, pass mark {PASS_PCT}%")
rows = [("study", "Study", "each objective's tasks, notes and solutions open", f"{done + states.count('studied')}/{n} objectives"),
("drill", "Clock", "the same tasks as a timed drill, graded per check", f"{done}/{n} objectives")]
rows.append(("mock", "Mock exam", f"{sum(QUOTA.values())} random tasks, {TRACK['minutes']} min, no feedback until submit", best(full)))
rows += [("sim", f"Set {k.upper()}", f"{len(set_questions(k))} fixed tasks, {TRACK['minutes']} min, exam rules", best(fixed[k])) for k in SETS]
print(bold("\nPath to the exam"))
for i, (kind, name, what, where) in enumerate(rows, 1):
here = step["kind"] == kind and (kind != "sim" or name == f"Set {step['set'].upper()}")
print(f" {green('→') if here else ' '} {i} {name:<11}{dim(f'{what:<54}')}{where}")
print(bold(f"\nNext: {step['cmd']}") + dim(" (or just ./exam next)"))
print(dim(f" {step['why']}"))
if step.get("objective"):
print(dim(f" ./exam notes {step['objective']['id']} what to know, the fastest commands, the traps, the docs to open"))
def cmd_next(args):
"""Start whatever is next on the path: study an objective, drill it, a mock, a simulator set."""
require_objectives()
step = next_step()
print(bold(f"Next on the path: {step['cmd']}") + dim(f"\n {step['why']}"))
if step["kind"] == "study":
cmd_study([step["objective"]["id"]] + args)
elif step["kind"] == "drill":
cmd_drill([step["objective"]["id"]] + args)
elif step["kind"] == "sim":
cmd_sim([step["set"]] + args)
else:
cmd_start((["--hard"] if step["kind"] == "done" else []) + args)
def paint_code(text):
"""`code` in a note: highlighted on a terminal, left as written (it is Markdown) in a pipe."""
return re.sub(r"`([^`]+)`", lambda m: bold(m.group(1)), text) if TTY else text
def print_notes(o, agg=None, studied=None):
agg = stats() if agg is None else agg
studied = load_study() if studied is None else studied
w = min(shutil.get_terminal_size((100, 24)).columns, 104)
def bullets(items):
for it in items:
print(paint_code("\n".join(textwrap.wrap(it, w, initial_indent=" - ", subsequent_indent=" "))))
print(bold(f"{o['id']} {o['key']}") + dim(f" · {DOMAINS[o['domain']]}"))
print(bold(o["text"])); print("-" * 64)
print(bold("Know")); bullets(o["know"])
print(bold("\nFast")); print("\n".join(" " + line for line in o["fast"].strip("\n").splitlines()))
print(bold("\nTraps")); bullets(o["traps"])
print(bold("\nDocs") + dim(" (pages you may open during the exam)"))
for u in o["docs"]:
print(" " + u)
print(bold("\nTasks"))
for q in objective_tasks(o, True):
print(f" {mark(task_state(q['id'], agg, studied))} Q{q['id']:<3} {q['title']}" + (dim(f" [{SETS.get(q['set'], q['set'])}]") if q.get("set") else ""))
print("-" * 64)
def notes_markdown(objs):
"""The same notes as a Markdown document (./exam notes all --md > CKAD_STUDY_GUIDE.md)."""
L = [f"# {TRACK_ID.upper()} study guide", "",
f"The {len(OBJECTIVES)} objectives of the {TRACK['name']} curriculum, in its own words and order: what to know, "
"the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks in this "
"simulator that practise each one.", "",
f"_Generated by `./exam --track {TRACK_ID} notes all --md` from `tracks/{TRACK_ID}/curriculum.py` - edit that file, not this one._", "",
"```", f"./exam objectives # the path and your progress", "./exam study 3.2 # one objective: these notes, then its tasks, no clock, solutions open",
"./exam drill 3.2 # the same tasks against the clock", "```", ""]
for d in objective_domains():
mine = [o for o in objs if o["domain"] == d]
if not mine:
continue
L += [f"## {DOMAINS[d]}", ""]
for o in mine:
L += [f"### {o['id']} {o['text']}", "", f"`./exam study {o['id']}` (or `{o['key']}`)", "", "**Know**", ""]
L += [f"- {x}" for x in o["know"]]
L += ["", "**Fast**", "", "```", o["fast"].strip("\n"), "```", "", "**Traps**", ""]
L += [f"- {x}" for x in o["traps"]]
L += ["", "**Docs**", ""] + [f"- <{u}>" for u in o["docs"]]
L += ["", "**Tasks**", ""]
L += [f"- Q{q['id']} {q['title']}" + (f" _({SETS.get(q['set'], q['set'])})_" if q.get("set") else "") for q in objective_tasks(o, True)]
L.append("")
return "\n".join(L)
def cmd_notes(args):
"""Study notes for an objective, a domain or the whole curriculum."""
require_objectives()
rest = [a for a in args if not a.startswith("--")]
if not rest:
st = load_state()
o = find_objective(st.get("objective")) if st and not st.get("ended_at") and st.get("objective") else None
objs = [o or next_objective()[0] or OBJECTIVES[0]]
elif rest[0] == "all":
objs = list(OBJECTIVES)
elif rest[0].upper() in DOMAINS:
objs = [o for o in OBJECTIVES if o["domain"] == rest[0].upper()]
else:
objs = [find_objective(a) for a in rest]
if None in objs:
die(f"'{rest[objs.index(None)]}' is not an objective. Ids and keys:\n "
+ "\n ".join(f"{o['id']:<5}{o['key']:<16}{o['text']}" for o in OBJECTIVES))
if "--md" in args:
print(notes_markdown(objs)); return
agg, studied = stats(), load_study()
for i, o in enumerate(objs):
if i:
print()
print_notes(o, agg, studied)
if len(objs) == 1:
print(dim(f"./exam study {objs[0]['id']} its tasks, no clock, solutions open ./exam drill {objs[0]['id']} against the clock"))
# ------------------------------------------------------------ session ---- # ------------------------------------------------------------ session ----
def preflight(): def preflight():
out = subprocess.run(["kubectl", "--context", CONTEXT, "get", "nodes", "-o", "jsonpath={.items[*].metadata.name}"], out = subprocess.run(["kubectl", "--context", CONTEXT, "get", "nodes", "-o", "jsonpath={.items[*].metadata.name}"],
@ -1240,10 +865,9 @@ if $K -n kube-system get deploy metrics-server >/dev/null 2>&1 \
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' >/dev/null 2>&1 || true -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' >/dev/null 2>&1 || true
fi fi
# cordons: Q60 cordons W2, Q109 has the control-plane node drained, and a drain task left half-done # cordons: Q60 cordons W2, and a drain task left half-done leaves a node cordoned. A cordon left
# leaves a node cordoned. A cordon left behind fails any later task that needs every worker (Q26's # behind fails any later task that needs every worker (Q26's DaemonSet coverage, Q50's spread).
# DaemonSet coverage, Q50's spread) or a Pod on the control-plane node (Q96, Q109 itself). for n in "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done
for n in "$CP" "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done
# Deliberately NOT restored, and why: # Deliberately NOT restored, and why:
# etcd (Q45) and certs (Q65). Neither setup damages anything - Q45 only takes a snapshot # etcd (Q45) and certs (Q65). Neither setup damages anything - Q45 only takes a snapshot
@ -1331,8 +955,7 @@ def use_kubectl():
except OSError: except OSError:
pass pass
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False, other_ok=None, def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False, other_ok=None):
objective=None):
st = load_state() st = load_state()
if st and not st.get("ended_at") and not force: if st and not st.get("ended_at") and not force:
die("A session is already in progress. './exam end' it first, or add --force.") die("A session is already in progress. './exam end' it first, or add --force.")
@ -1352,7 +975,6 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
if not qs: if not qs:
die(f"Track '{TRACK_ID}' has no questions yet.") die(f"Track '{TRACK_ID}' has no questions yet.")
preflight() preflight()
update_milestones() # stamp what the history already proves, so this session only announces its own
os.makedirs(WORKSPACE, exist_ok=True) os.makedirs(WORKSPACE, exist_ok=True)
rng = random.Random() rng = random.Random()
params_by_id = {str(q["id"]): make_params(q, rng) for q in qs} params_by_id = {str(q["id"]): make_params(q, rng) for q in qs}
@ -1363,24 +985,19 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
seed(qs, log, params_by_id) seed(qs, log, params_by_id)
st = {"mode": mode, "track": TRACK_ID, "hard": hard, "untimed": untimed_session, "ids": [q["id"] for q in qs], st = {"mode": mode, "track": TRACK_ID, "hard": hard, "untimed": untimed_session, "ids": [q["id"] for q in qs],
"params": params_by_id, "duration": duration, "started_at": time.time(), "ended_at": None, "params": params_by_id, "duration": duration, "started_at": time.time(), "ended_at": None,
"k8s": server_version(), "results": {}, "flags": [], "notes": "", "k8s": server_version(), "results": {}, "flags": [], "notes": ""}
"objective": objective} # the curriculum objective a study/drill session was started for, if any
save_state(st) save_state(st)
log("") log("")
log(bold("=" * 64)) log(bold("=" * 64))
kind = "STUDY" if untimed_session else ("EXAM" if mode == "exam" else "DRILL") log(bold(f" {TRACK_ID.upper()} {'EXAM' if mode == 'exam' else 'DRILL'}{' · HARD' if hard else ''} - clock started"))
log(bold(f" {TRACK_ID.upper()} {kind}{' · HARD' if hard else ''} - {'no clock, solutions open' if untimed_session else 'clock started'}"))
log(f" Cluster : {CONTEXT} (Kubernetes {st['k8s'] or K8S}, as the real {TRACK_ID.upper()} runs {K8S})") log(f" Cluster : {CONTEXT} (Kubernetes {st['k8s'] or K8S}, as the real {TRACK_ID.upper()} runs {K8S})")
if hard and TRACK.get("exam_contexts"): if hard and TRACK.get("exam_contexts"):
log(yellow(" Contexts: k8s-c1-H (default) · k8s-c2-AC · k8s-c3-CCC - each task tells you which to use; you are on k8s-c1-H now")) log(yellow(" Contexts: k8s-c1-H (default) · k8s-c2-AC · k8s-c3-CCC - each task tells you which to use; you are on k8s-c1-H now"))
if not untimed_session: log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)")
log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)") log(f" Tasks : {', '.join('Q%02d' % q['id'] for q in qs) if mode == 'drill' else len(qs)} Pass mark: {PASS_PCT}%")
log(f" Tasks : {', '.join('Q%02d' % q['id'] for q in qs) if mode == 'drill' else len(qs)}"
+ ("" if untimed_session else f" Pass mark: {PASS_PCT}%"))
log(bold("=" * 64)) log(bold("=" * 64))
log(" ./exam web exam-style browser UI (task pane + terminal + clock)\n" log(" ./exam web exam-style browser UI (task pane + terminal + clock)\n"
+ (" ./exam list / show N / grade N / solution N / end terminal workflow\n" if untimed_session else " ./exam list / show N / grade N / timer / end terminal workflow\n"
" ./exam list / show N / grade N / timer / end terminal workflow\n")
+ (" Tip: read ALL tasks first. Some are cluster-wide failures that block others.\n" if mode == "exam" else "") + (" Tip: read ALL tasks first. Some are cluster-wide failures that block others.\n" if mode == "exam" else "")
+ f" Files the tasks ask for go under {WS_DISPLAY}/ (relative to this directory)\n" + f" Files the tasks ask for go under {WS_DISPLAY}/ (relative to this directory)\n"
+ " Your own terminal: source ./examrc (fish: source ./examrc.fish) -> k, $do, $now, completion") + " Your own terminal: source ./examrc (fish: source ./examrc.fish) -> k, $do, $now, completion")
@ -1400,15 +1017,9 @@ def finish_session(st, log: Callable[..., Any] = _log_msg):
st["over_time"] = over st["over_time"] = over
save_state(st) save_state(st)
setup_contexts(False, {}) setup_contexts(False, {})
entry = None
if untimed(st): if untimed(st):
record_study(st) return None
else: return record_attempt(st)
entry = record_attempt(st)
# objectives this session got over a line: shown by ./exam end and by the results page
st["milestones"] = st.get("milestones", []) + milestone_lines(update_milestones())
save_state(st)
return entry
# ----------------------------------------------------------- commands ---- # ----------------------------------------------------------- commands ----
def cmd_start(args): def cmd_start(args):
@ -1451,7 +1062,6 @@ def cmd_drill(args):
print(bold(f"Campaign: {label}") + dim(f" this batch: Q{', Q'.join(str(q['id']) for q in qs)}")) print(bold(f"Campaign: {label}") + dim(f" this batch: Q{', Q'.join(str(q['id']) for q in qs)}"))
begin_session(qs, "drill", (minutes * 60) if minutes else per_task * len(qs), force, hard=hard) begin_session(qs, "drill", (minutes * 60) if minutes else per_task * len(qs), force, hard=hard)
return return
objective = None
if args[0] == "weak": if args[0] == "weak":
ids = weak_ids() ids = weak_ids()
if not ids: if not ids:
@ -1465,16 +1075,9 @@ def cmd_drill(args):
ids += [q["id"] for q in QUESTIONS if q["domain"] == a.upper()] ids += [q["id"] for q in QUESTIONS if q["domain"] == a.upper()]
elif a.isdigit(): elif a.isdigit():
ids.append(q_by_id(int(a))["id"]) ids.append(q_by_id(int(a))["id"])
elif find_objective(a):
sitting, open_n = objective_session(find_objective(a), "drill")
ids += [q["id"] for q in sitting]
objective = objective or find_objective(a)["id"]
if open_n > len(sitting):
print(dim(f" {find_objective(a)['id']}: {len(sitting)} of its {open_n} open tasks in this sitting - run it again for the rest."))
else: else:
die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), a set " die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}) or a set "
f"({', '.join('set' + k for k in SETS) or 'none'})" f"({', '.join('set' + k for k in SETS) or 'none'}).")
+ (" or an objective (./exam objectives)." if OBJECTIVES else "."))
ids = sorted(set(ids)) ids = sorted(set(ids))
qs, solo = split_solo([q for q in QUESTIONS if q["id"] in ids]) qs, solo = split_solo([q for q in QUESTIONS if q["id"] in ids])
if solo: if solo:
@ -1488,18 +1091,16 @@ def cmd_drill(args):
# order you meet them in does not. A drill listed by id walks you from easy to hard in # order you meet them in does not. A drill listed by id walks you from easy to hard in
# lockstep, which is the one ordering the real exam will never give you. # lockstep, which is the one ordering the real exam will never give you.
random.Random().shuffle(kept) random.Random().shuffle(kept)
begin_session(kept, "drill", (minutes * 60) if minutes else per_task * len(kept), force, hard=hard, begin_session(kept, "drill", (minutes * 60) if minutes else per_task * len(kept), force, hard=hard)
objective=objective if len(args) == 1 else None)
def cmd_study(args): def cmd_study(args):
"""No clock, solutions unlocked from the start, nothing written to history.""" """No clock, solutions unlocked from the start, nothing written to history."""
hard = "--hard" in args; force = "--force" in args; with_sets = "--all" in args hard = "--hard" in args; force = "--force" in args
rest = [a for a in args if not a.startswith("--")] rest = [a for a in args if not a.startswith("--")]
n = DRILL_SIZE n = DRILL_SIZE
if "--count" in args: if "--count" in args:
n = int(args[args.index("--count") + 1]); rest = [a for a in rest if a != str(n)] n = int(args[args.index("--count") + 1]); rest = [a for a in rest if a != str(n)]
rng = random.Random() rng = random.Random()
obj = None
if rest and rest[0].upper() in DOMAINS: if rest and rest[0].upper() in DOMAINS:
domain = rest[0].upper() domain = rest[0].upper()
qs = pick(domain, n, hard, rng) qs = pick(domain, n, hard, rng)
@ -1507,33 +1108,13 @@ def cmd_study(args):
elif rest and rest[0] == "weak": elif rest and rest[0] == "weak":
qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])[0], n) qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])[0], n)
print(bold("Study: tasks you have been failing")) print(bold("Study: tasks you have been failing"))
elif OBJECTIVES and (not rest or rest[0] == "next" or find_objective(rest[0])):
# one objective of the curriculum: the one named, else the next one on the path
if rest and rest[0] != "next":
obj = find_objective(rest[0])
else:
agg, studied = stats(), load_study()
obj = next((o for o in path_objectives() if objective_state(o, agg, studied) in ("new", "started")), None)
if obj is None:
die("Every objective has been studied - name one to revisit (./exam objectives lists them),\n"
f" or carry on up the path: {next_step(agg, studied)['cmd']} (./exam next)")
qs, open_n = objective_session(obj, "study", with_sets, n if "--count" in args else None)
print(bold(f"Study: {obj['id']} {obj['text']}") + dim(f" {DOMAINS[obj['domain']]}"))
if open_n > len(qs):
print(dim(f" {len(qs)} of its {open_n} open tasks in this sitting - run the same command again for the rest."))
elif rest: elif rest:
qs = no_conflicts(split_solo([q_by_id(int(a)) if a.isdigit() else die( qs = no_conflicts(split_solo([q_by_id(int(a)) for a in rest])[0], len(rest))
f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), 'weak'"
+ (" or an objective (./exam objectives)." if OBJECTIVES else ".")) for a in rest])[0], len(rest))
print(bold("Study: " + ", ".join(f"Q{q['id']}" for q in qs))) print(bold("Study: " + ", ".join(f"Q{q['id']}" for q in qs)))
else: else:
domain, qs = next_drill(hard, rng) domain, qs = next_drill(hard, rng)
print(bold(f"Study: {DOMAINS[domain]}")) print(bold(f"Study: {DOMAINS[domain]}"))
begin_session(qs, "drill", EXAM_SECONDS, force, hard=hard, untimed_session=True, objective=obj and obj["id"]) begin_session(qs, "drill", EXAM_SECONDS, force, hard=hard, untimed_session=True)
if obj:
print(); print_notes(obj)
print(dim(" ./exam show N read a task ./exam grade N check it ./exam solution N reference answer\n"
" ./exam notes these notes again ./exam end finish (./exam objectives then shows what you solved)"))
def cmd_sim(args): def cmd_sim(args):
"""A fixed simulator set, full exam rules and clock.""" """A fixed simulator set, full exam rules and clock."""
@ -1594,13 +1175,6 @@ def cmd_grade(args):
tag = green("PASS") if r.get("pass") else yellow(f"{r.get('earned')}/{r.get('total')}") tag = green("PASS") if r.get("pass") else yellow(f"{r.get('earned')}/{r.get('total')}")
print(f"Q{n:02d} {tag} {q['title']}"); print(dim(result_detail(r))) print(f"Q{n:02d} {tag} {q['title']}"); print(dim(result_detail(r)))
save_state(st) save_state(st)
record_study(st, set(ids))
if untimed(st):
reached = milestone_lines(update_milestones())
if reached: # kept with the session, so ./exam end and the results page show them again
st["milestones"] = st.get("milestones", []) + reached; save_state(st)
for line in reached:
print(green(line))
if args[0] == "all": if args[0] == "all":
print_score(st) print_score(st)
@ -1620,7 +1194,7 @@ def print_score(st):
def status_line(st): def status_line(st):
if untimed(st): if untimed(st):
return green("study mode - no clock") + dim(f" [{len(session_questions(st))} tasks, kept out of the history]") return green("study mode - no clock") + dim(f" [{len(session_questions(st))} tasks, nothing recorded]")
r = remaining(st) r = remaining(st)
if st.get("paused_at"): if st.get("paused_at"):
return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./exam resume") return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./exam resume")
@ -1678,22 +1252,13 @@ def cmd_end(args):
print(yellow("Note: time expired before submission.")) print(yellow("Note: time expired before submission."))
entry = finish_session(st) entry = finish_session(st)
print_score(st) print_score(st)
def path_note():
if not OBJECTIVES:
return
print()
for line in st.get("milestones", []):
print(green(line))
step = next_step()
print(bold(f"Next on the path: {step['cmd']}") + dim(f" {step['why']}"))
if entry is None: if entry is None:
print(dim("Study session - not added to history or weak-spots.")); path_note(); return print(dim("Study session - not added to history or weak-spots.")); return
print(dim(f"Time used: {fmt(st['ended_at'] - st['started_at'] - paused_total(st))}" + (" (over time)" if st.get("over_time") else ""))) print(dim(f"Time used: {fmt(st['ended_at'] - st['started_at'] - paused_total(st))}" + (" (over time)" if st.get("over_time") else "")))
failed = sorted(int(k) for k, v in entry["results"].items() if not v["pass"]) failed = sorted(int(k) for k, v in entry["results"].items() if not v["pass"])
print(dim(f"Attempt #{len(load_history())} logged to {os.path.basename(HISTORY_FILE)}; {os.path.basename(WEAK_FILE)} updated.")) print(dim(f"Attempt #{len(load_history())} logged to {os.path.basename(HISTORY_FILE)}; {os.path.basename(WEAK_FILE)} updated."))
if failed: if failed:
print(bold("\nTo drill what you missed: ./exam drill weak") + dim(f" (Q{', Q'.join(map(str, failed))})")) print(bold("\nTo drill what you missed: ./exam drill weak") + dim(f" (Q{', Q'.join(map(str, failed))})"))
path_note()
def cmd_abort(args): def cmd_abort(args):
"""End a session without grading it and restore the kubectl context.""" """End a session without grading it and restore the kubectl context."""
@ -1821,10 +1386,7 @@ class Api(http.server.BaseHTTPRequestHandler):
elif mode == "exam": elif mode == "exam":
begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard, other_ok=False) begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard, other_ok=False)
else: else:
objective = find_objective(body["objective"]) if body.get("objective") else None if body.get("domain") in DOMAINS:
if objective:
qs, _ = objective_session(objective, "study" if body.get("study") else "drill", bool(body.get("all")))
elif body.get("domain") in DOMAINS:
qs = pick(body["domain"], DRILL_SIZE, hard, rng) qs = pick(body["domain"], DRILL_SIZE, hard, rng)
elif body.get("campaign"): elif body.get("campaign"):
_, qs = campaign_batch(hard, rng) _, qs = campaign_batch(hard, rng)
@ -1837,8 +1399,7 @@ class Api(http.server.BaseHTTPRequestHandler):
qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in ids])[0], len(ids)) qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in ids])[0], len(ids))
per = DRILL_SECONDS_PER_TASK_HARD if hard else DRILL_SECONDS_PER_TASK per = DRILL_SECONDS_PER_TASK_HARD if hard else DRILL_SECONDS_PER_TASK
begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard, begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard,
untimed_session=bool(body.get("study")), other_ok=False, untimed_session=bool(body.get("study")), other_ok=False)
objective=objective and objective["id"])
except SystemExit as e: except SystemExit as e:
Api.seed_error = (getattr(e, "msg", None) or "\n".join(l for l in Api.log_lines if l.strip()) Api.seed_error = (getattr(e, "msg", None) or "\n".join(l for l in Api.log_lines if l.strip())
or "start failed - see the terminal running ./exam web") or "start failed - see the terminal running ./exam web")
@ -1864,9 +1425,6 @@ class Api(http.server.BaseHTTPRequestHandler):
if "results" not in st or not isinstance(st["results"], dict): if "results" not in st or not isinstance(st["results"], dict):
st["results"] = {} st["results"] = {}
st["results"][str(q["id"])] = grade_one(q, session_params(st, q)); save_state(st) st["results"][str(q["id"])] = grade_one(q, session_params(st, q)); save_state(st)
record_study(st, {q["id"]})
if untimed(st):
st["milestones"] = st.get("milestones", []) + milestone_lines(update_milestones()); save_state(st)
elif p.path == "/api/end": elif p.path == "/api/end":
if not st.get("ended_at"): if not st.get("ended_at"):
if not kubectl_ok(): # grading an unreachable cluster would record a 0% attempt if not kubectl_ok(): # grading an unreachable cluster would record a 0% attempt
@ -1886,9 +1444,7 @@ class Api(http.server.BaseHTTPRequestHandler):
"ttyd": shutil.which("ttyd") is not None, "shell": Api.shell, "setup_log": Api.log_lines, "ttyd": shutil.which("ttyd") is not None, "shell": Api.shell, "setup_log": Api.log_lines,
"questions": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "weight": q["weight"], "difficulty": q["difficulty"], "questions": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "weight": q["weight"], "difficulty": q["difficulty"],
"task": task_text(q, session_params(st, q)), "checks": [{"name": c["name"], "pts": c["pts"]} for c in q["checks"]], "task": task_text(q, session_params(st, q)), "checks": [{"name": c["name"], "pts": c["pts"]} for c in q["checks"]],
"solution": render(str(q.get("solution", "")), session_params(st, q)) if solution_allowed(st, int(q["id"])) else None, "solution": render(str(q.get("solution", "")), session_params(st, q)) if solution_allowed(st, int(q["id"])) else None}
# which objective a task belongs to is a hint, so it follows the solution's rule
"objectives": [o["id"] for o in objectives_of(q["id"])] if solution_allowed(st, int(q["id"])) else []}
for q in qs], for q in qs],
"bank": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "difficulty": q["difficulty"]} for q in QUESTIONS], "bank": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "difficulty": q["difficulty"]} for q in QUESTIONS],
"tracks": TRACKS, "track_k8s": track_k8s(), "tracks": TRACKS, "track_k8s": track_k8s(),
@ -1897,7 +1453,6 @@ class Api(http.server.BaseHTTPRequestHandler):
"seeding": Api.seeding, "seed_error": Api.seed_error, "seeding": Api.seeding, "seed_error": Api.seed_error,
"session": None, "session": None,
} }
payload.update(self.path_payload())
if st: if st:
earned, total, pct = score(st) earned, total, pct = score(st)
payload["session"] = {"mode": st.get("mode"), "hard": st.get("hard", False), "started_at": st["started_at"], "duration": st.get("duration", EXAM_SECONDS), payload["session"] = {"mode": st.get("mode"), "hard": st.get("hard", False), "started_at": st["started_at"], "duration": st.get("duration", EXAM_SECONDS),
@ -1905,29 +1460,10 @@ class Api(http.server.BaseHTTPRequestHandler):
"over_time": st.get("over_time", False), "flags": st.get("flags", []), "over_time": st.get("over_time", False), "flags": st.get("flags", []),
"paused": bool(st.get("paused_at")), "paused_total": paused_total(st), "paused": bool(st.get("paused_at")), "paused_total": paused_total(st),
"untimed": untimed(st), "notes": st.get("notes", ""), "untimed": untimed(st), "notes": st.get("notes", ""),
"objective": st.get("objective"), "milestones": st.get("milestones", []),
"results": st["results"], "score": {"earned": earned, "total": total, "pct": pct, "pass_pct": PASS_PCT}, "results": st["results"], "score": {"earned": earned, "total": total, "pct": pct, "pass_pct": PASS_PCT},
"domains": self.domain_scores(st), "server_time": time.time()} "domains": self.domain_scores(st), "server_time": time.time()}
return payload return payload
def path_payload(self) -> dict[str, Any]:
"""The curriculum as the UI shows it: every objective with its notes, tasks and state, how
many are studied and done, and the next step on the path. Empty for a track without one."""
if not OBJECTIVES:
return {"objectives": [], "path": None}
agg, studied = stats(), load_study()
objs = [{"id": o["id"], "key": o["key"], "domain": o["domain"], "text": o["text"], "state": objective_state(o, agg, studied),
"know": o["know"], "fast": o["fast"].strip("\n"), "traps": o["traps"], "docs": o["docs"],
"tasks": [{"id": q["id"], "title": q["title"], "set": q.get("set"), "state": task_state(q["id"], agg, studied)}
for q in objective_tasks(o, True)]} for o in OBJECTIVES]
step = next_step(agg, studied); full, fixed = mock_scores()
states = [o["state"] for o in objs]
return {"objectives": objs,
"path": {"total": len(objs), "done": states.count("done"), "studied": states.count("done") + states.count("studied"),
"mock_best": max(full) if full else None, "set_best": {k: (max(v) if v else None) for k, v in fixed.items()},
"next": {"kind": step["kind"], "cmd": step["cmd"], "why": step["why"], "set": step.get("set"),
"objective": step["objective"]["id"] if step.get("objective") else None}}}
def domain_scores(self, st: dict[str, Any]) -> dict[str, Any]: def domain_scores(self, st: dict[str, Any]) -> dict[str, Any]:
qs = session_questions(st); out = {} qs = session_questions(st); out = {}
st_results: dict[str, Any] = st.get("results", {}) st_results: dict[str, Any] = st.get("results", {})
@ -2014,18 +1550,12 @@ def cmd_clusters(args):
def cmd_help(args): def cmd_help(args):
print(__doc__ + f"\nversion {VERSION}") print(__doc__ + f"\nversion {VERSION}")
objective_help = "" if not OBJECTIVES else (
f" objectives the curriculum as a path to the exam: {len(OBJECTIVES)} objectives, their tasks, your progress\n"
" next start the next step on that path (study an objective, then drill it, then the mocks)\n"
" notes [OBJ|DOMAIN|all] what to know, fastest commands, traps and docs for an objective (3.2 or probes)\n"
" study [OBJ|next] an objective's notes and tasks; with no argument, the next one on the path\n"
" (--all adds its Simulator Set tasks); drill OBJ = the same tasks against the clock\n")
print(f"""usage: ./exam [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks) print(f"""usage: ./exam [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks)
Each track runs on a kind cluster at its exam's Kubernetes version - {TRACK_ID}: {K8S} on {CONTEXT}. Each track runs on a kind cluster at its exam's Kubernetes version - {TRACK_ID}: {K8S} on {CONTEXT}.
web [--port 8080] [--shell bash] exam-style browser UI: tasks, terminal, clock, submit web [--port 8080] [--shell bash] exam-style browser UI: tasks, terminal, clock, submit
study [DOMAIN|weak|N..] no clock, solutions unlocked, kept out of the history (--count N, default {DRILL_SIZE}) study [DOMAIN|weak|N..] no clock, solutions unlocked, nothing recorded (--count N, default {DRILL_SIZE})
{objective_help} drill next domain you have drilled least: {DRILL_SIZE} tasks, 6 min each (--hard: 5 min, hard tasks) drill next domain you have drilled least: {DRILL_SIZE} tasks, 6 min each (--hard: 5 min, hard tasks)
drill all [--hard] campaign: {DRILL_SIZE} never-attempted tasks per session until the bank is done, then weak ones drill all [--hard] campaign: {DRILL_SIZE} never-attempted tasks per session until the bank is done, then weak ones
drill weak|N..|DOMAIN.. chosen tasks (--time MIN, --force, --hard) drill weak|N..|DOMAIN.. chosen tasks (--time MIN, --force, --hard)
progress attempted/passed per domain, normal and hard progress attempted/passed per domain, normal and hard
@ -2049,8 +1579,7 @@ def cmd_help(args):
COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade, COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade,
"status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history, "status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history,
"reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "study": cmd_study, "version": cmd_version, "help": cmd_help, "reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "study": cmd_study, "version": cmd_version, "help": cmd_help,
"clusters": cmd_clusters, "cluster-env": cmd_cluster_env, "clusters": cmd_clusters, "cluster-env": cmd_cluster_env}
"objectives": cmd_objectives, "curriculum": cmd_objectives, "notes": cmd_notes, "next": cmd_next}
if __name__ == "__main__": if __name__ == "__main__":
cmd = ARGV[0] if ARGV else "help" cmd = ARGV[0] if ARGV else "help"

View File

@ -1,638 +0,0 @@
"""CKA tasks 103-113: pool tasks for the curriculum objectives the bank covered thinly or not at all.
curriculum.py maps every task to the objectives of the CKA curriculum and turns the bank into a
study path (./exam objectives). Mapping the first 102 tasks left eight objectives with fewer than
two pool tasks; these close them:
2.3 Monitor cluster and application resource usage 103 kubectl top
2.4 Manage and evaluate container output streams 104 logs by selector, of one replica, of an init container
3.3 Configure workload autoscaling 105 an HPA on CPU with a scale-down policy
3.4 Robust, self-healing deployments 106 probes that check the wrong thing
4.2 Prepare underlying infrastructure 107 kernel modules and sysctls 108 the runtime behind a Pod
4.4 Manage the lifecycle of clusters 109 drain the control-plane node and bring it back
4.5 Highly-available control plane 110 the join of a second control-plane node 111 leaders and etcd members
4.7 Extension interfaces (CNI, CSI, CRI) 112 what this cluster plugs in (and 108)
5.1 Connectivity between Pods 113 Pod to Pod across nodes, Pod CIDRs, hostNetwork
103, 104 and 106 are the CKAD tasks for the same skill (kubectl top, logs, probes) under CKA ids:
the objective is word for word the same, so is the scenario. Everything else is CKA ground - nodes,
kubeadm, etcd, the runtime. All normal difficulty, all in the random pool; nothing here breaks a
node or a control-plane component (109 cordons the control-plane node while you work on it and has
you uncordon it; the session baseline uncordons it if you do not).
"""
from tracks.cka.common import C, NS, ns_pick, name_pick
from tracks.ckad import bank_extra as _ckad_extra, bank_more as _ckad_more
def _port(bank, src_id, new_id, domain, **over):
"""A CKAD task that serves a CKA objective as it is: same scenario and graders, CKA id and paths."""
src = next(q for q in bank.QUESTIONS if q["id"] == src_id)
old, new = f"q{src_id}", f"q{new_id}"
q = dict(src, id=new_id, domain=domain, **over)
for k in ("task", "setup", "solution"):
q[k] = src[k].replace(old, new)
q["checks"] = [dict(c, script=c["script"].replace(old, new), name=c["name"].replace(old, new)) for c in src["checks"]]
return q
ETCDCTL = ("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")
QUESTIONS = [
# ---------------------------------------------------------------- TSHOOT ----
# needs a working metrics API, which Q23 breaks on purpose: never in one session with it
_port(_ckad_extra, 23, 103, "TSHOOT", breaks="metrics-server"),
_port(_ckad_more, 64, 104, "TSHOOT"),
# ------------------------------------------------------------------ WORK ----
{
"id": 105, "domain": "WORK", "title": "Autoscale on CPU, scale down calmly",
"params": lambda r: {"ns": ns_pick(r, "scale"), "dep": name_pick(r, "render"), "min": r.choice([2, 3]),
"max": r.choice([6, 8, 10]), "cpu": r.choice([50, 60, 70]), "win": r.choice([120, 180, 300])},
"task": """
Deployment `%{dep}` in Namespace `%{ns}` has to scale with its load.
1. An autoscaler that works on CPU utilisation needs a CPU request to measure against. Give the
container `web` a CPU request of `100m` and leave everything else in the Deployment as it is.
2. Create HorizontalPodAutoscaler `%{dep}` for the Deployment: at least **%{min}** replicas, at
most **%{max}**, target average CPU utilisation **%{cpu}%**.
3. Scaling down must be calm: a stabilisation window of **%{win}** seconds, and at most **1 Pod
removed per 60 seconds**. Leave scaling up at its defaults.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete hpa --all >/dev/null 2>&1
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}, labels: {app: %{dep}}}
spec:
replicas: 1
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: web
image: nginx:1.27
ports: [{containerPort: 80}]
resources: {requests: {memory: 32Mi}}
EOF
$K -n %{ns} rollout status deploy %{dep} --timeout=150s >/dev/null
""",
"checks": [
C("container web requests cpu 100m, the rest of it unchanged", 2, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].name}/{.spec.template.spec.containers[0].image}/{.spec.template.spec.containers[0].resources.requests.cpu}/{.spec.template.spec.containers[0].resources.requests.memory}')" \
"web/nginx:1.27/100m/32Mi" """),
C("HPA %{dep}: Deployment %{dep}, %{min}-%{max} replicas, %{cpu}% average CPU utilisation", 2, r"""
$K -n %{ns} get hpa %{dep} -o json 2>/dev/null | python3 -c '
import sys, json
s = json.load(sys.stdin)["spec"]; t = s["scaleTargetRef"]; m = s.get("metrics") or []
cpu = [x["resource"]["target"] for x in m if x.get("type") == "Resource" and x["resource"]["name"] == "cpu"]
ok = (t["kind"], t["name"]) == ("Deployment", "%{dep}") and (s.get("minReplicas"), s["maxReplicas"]) == (%{min}, %{max}) \
and len(m) == 1 and len(cpu) == 1 and cpu[0].get("type") == "Utilization" and cpu[0].get("averageUtilization") == %{cpu}
sys.exit(0 if ok else 1)'"""),
C("scale-down: %{win} s stabilisation window, at most 1 Pod per 60 s", 2, r"""
$K -n %{ns} get hpa %{dep} -o json 2>/dev/null | python3 -c '
import sys, json
d = (json.load(sys.stdin)["spec"].get("behavior") or {}).get("scaleDown") or {}
p = d.get("policies") or []
ok = d.get("stabilizationWindowSeconds") == %{win} and len(p) == 1 \
and (p[0].get("type"), p[0].get("value"), p[0].get("periodSeconds")) == ("Pods", 1, 60)
sys.exit(0 if ok else 1)'"""),
C("the autoscaler has taken the Deployment to at least %{min} replicas", 1, r"""
$K -n %{ns} get hpa %{dep} >/dev/null 2>&1 || exit 1
for i in $(seq 1 20); do
[ "$(j -n %{ns} deploy %{dep} '{.spec.replicas}')" -ge %{min} ] 2>/dev/null && exit 0; sleep 3
done; exit 1"""),
],
"solution": """
k -n %{ns} set resources deploy %{dep} -c web --requests=cpu=100m
k -n %{ns} autoscale deploy %{dep} --min=%{min} --max=%{max} --cpu=%{cpu}% # kubectl before 1.34: --cpu-percent=%{cpu}
k -n %{ns} edit hpa %{dep} # `autoscale` cannot write behaviour - add under spec:
# behavior:
# scaleDown:
# stabilizationWindowSeconds: %{win}
# policies:
# - {type: Pods, value: 1, periodSeconds: 60}
k -n %{ns} get hpa %{dep} # MINPODS %{min}, MAXPODS %{max}; REPLICAS climbs to %{min}
k -n %{ns} describe hpa %{dep}
# The whole object, for when you write it instead:
# apiVersion: autoscaling/v2
# kind: HorizontalPodAutoscaler
# metadata: {name: %{dep}, namespace: %{ns}}
# spec:
# scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: %{dep}}
# minReplicas: %{min}
# maxReplicas: %{max}
# metrics:
# - type: Resource
# resource: {name: cpu, target: {type: Utilization, averageUtilization: %{cpu}}}
# behavior: {scaleDown: {stabilizationWindowSeconds: %{win}, policies: [{type: Pods, value: 1, periodSeconds: 60}]}}
# Utilisation is usage divided by the REQUEST, so without a CPU request the HPA shows <unknown>
# and never scales on load. It does enforce minReplicas at once, metrics or not. TARGETS stays
# <unknown> here until metrics-server is installed and has a minute of data.
""",
},
_port(_ckad_more, 62, 106, "WORK"),
# ------------------------------------------------------------------ ARCH ----
{
"id": 107, "domain": "ARCH", "title": "Kernel prerequisites for a Kubernetes node",
"params": lambda r: {"name": r.choice(["k8s", "kubernetes", "99-kubernetes-cri"])},
"task": """
Node `{W3}` is to be prepared the way a machine is before `kubeadm` puts Kubernetes on it. Reach
it with `{SHELL_W3}`.
1. Make the kernel modules `overlay` and `br_netfilter` load at every boot: list them, one per
line, in `/etc/modules-load.d/%{name}.conf`.
2. Make these kernel parameters permanent in `/etc/sysctl.d/%{name}.conf`:
`net.bridge.bridge-nf-call-iptables = 1`, `net.bridge.bridge-nf-call-ip6tables = 1` and
`net.ipv4.ip_forward = 1`.
3. Write what the node reports right now to `{WS}/q107/node.txt`, in exactly this format:
```
ip_forward=<current value of net.ipv4.ip_forward>
swap=<on or off - is any swap device active on the node>
fail-swap-on=<the kubelet's failSwapOn setting>
cgroup-driver=<the kubelet's cgroupDriver>
```
(The nodes of this simulator are containers, so a module cannot really be loaded here: the two
files and the report are what is graded.)
""",
"setup": r"""
rm -rf "$WS/q107"; mkdir -p "$WS/q107"
nx "$W3" 'rm -f /etc/modules-load.d/%{name}.conf /etc/sysctl.d/%{name}.conf' </dev/null
""",
"checks": [
C("/etc/modules-load.d/%{name}.conf lists overlay and br_netfilter", 2, r"""
got=$(nx "$W3" "grep -Ev '^[[:space:]]*(#|\$)' /etc/modules-load.d/%{name}.conf 2>/dev/null" </dev/null | tr -d ' \t\r' | sort | tr '\n' ' ')
eq "$got" "br_netfilter overlay " """),
C("/etc/sysctl.d/%{name}.conf sets the three parameters to 1", 3, r"""
f=$(nx "$W3" "cat /etc/sysctl.d/%{name}.conf 2>/dev/null" </dev/null | tr -d ' \t\r')
[ -n "$f" ] || exit 1
for kv in net.bridge.bridge-nf-call-iptables=1 net.bridge.bridge-nf-call-ip6tables=1 net.ipv4.ip_forward=1; do
echo "$f" | grep -qx "$kv" || exit 1
done"""),
C("node.txt: ip_forward, swap, fail-swap-on and cgroup-driver as the node has them", 2, r"""
fwd=$(nx "$W3" "sysctl -n net.ipv4.ip_forward" </dev/null | tr -d '[:space:]')
swap=off; [ "$(nx "$W3" "sed 1d /proc/swaps | grep -c ." </dev/null | tr -d '[:space:]')" -gt 0 ] 2>/dev/null && swap=on
fso=$(nx "$W3" "awk '/^failSwapOn:/{print \$2}' /var/lib/kubelet/config.yaml" </dev/null | tr -d '[:space:]'); [ -n "$fso" ] || fso=true
cg=$(nx "$W3" "awk '/^cgroupDriver:/{print \$2}' /var/lib/kubelet/config.yaml" </dev/null | tr -d '[:space:]')
[ -n "$fwd" ] && [ -n "$cg" ] || exit 1
want=$(printf 'ip_forward=%s\nswap=%s\nfail-swap-on=%s\ncgroup-driver=%s' "$fwd" "$swap" "$fso" "$cg")
eq "$(tr -d ' \t\r' < "$WS/q107/node.txt" 2>/dev/null | grep .)" "$want" """),
],
"solution": """
{SHELL_W3}
printf 'overlay\\nbr_netfilter\\n' > /etc/modules-load.d/%{name}.conf
cat > /etc/sysctl.d/%{name}.conf <<EOF
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
# on a real machine you now load and apply them without a reboot:
# modprobe overlay && modprobe br_netfilter && sysctl --system
sysctl -n net.ipv4.ip_forward # 1
cat /proc/swaps # any line under the header = swap is on (swapon --show)
grep -E 'failSwapOn|cgroupDriver' /var/lib/kubelet/config.yaml
exit
cat > {WS}/q107/node.txt <<EOF
ip_forward=1
swap=<on|off>
fail-swap-on=<true|false>
cgroup-driver=<systemd|cgroupfs>
EOF
# `sysctl -w` and `modprobe` alone are gone after a reboot - the files are what makes them stick.
# kubeadm's preflight refuses a node with swap on unless the kubelet is told to tolerate it
# (failSwapOn: false), and the kubelet's cgroup driver has to match the container runtime's
# (SystemdCgroup = true in /etc/containerd/config.toml goes with cgroupDriver: systemd).
""",
},
{
"id": 108, "domain": "ARCH", "title": "The container runtime behind a Pod",
"params": lambda r: {"ns": ns_pick(r, "runtime"), "pod": name_pick(r, "probe")},
"task": """
Pod `%{pod}` in Namespace `%{ns}` runs on node `{W2}`. Look at it from the node's side, through
the container runtime (`{SHELL_W2}`, then `crictl`), and write `{WS}/q108/runtime.txt` in exactly
this format:
```
runtime=<runtime name> <runtime version>
socket=<the runtime endpoint the kubelet on that node is configured with>
container-id=<the full ID of the Pod's container `app`>
pid=<the process ID of that container on the node>
log=<the full path of that container's log file on the node>
```
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q108"; mkdir -p "$WS/q108"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
terminationGracePeriodSeconds: 1
nodeSelector: {kubernetes.io/hostname: "{W2}"}
containers:
- name: app
image: busybox:1.36
command: [sh, -c, "echo 'runtime probe started'; sleep 86400"]
EOF
$K -n %{ns} wait --for=condition=Ready pod/%{pod} --timeout=150s >/dev/null
""",
"checks": [
C("runtime and socket lines", 2, r"""
ver=$(nx "$W2" "crictl version 2>/dev/null" </dev/null)
name=$(echo "$ver" | awk '/^RuntimeName:/{print $2}'); rv=$(echo "$ver" | awk '/^RuntimeVersion:/{print $2}')
sock=$(nx "$W2" "awk '/^containerRuntimeEndpoint:/{print \$2}' /var/lib/kubelet/config.yaml" </dev/null | tr -d '"[:space:]')
[ -n "$name" ] && [ -n "$rv" ] && [ -n "$sock" ] || exit 1
f="$WS/q108/runtime.txt"
tr -d '\r' < "$f" 2>/dev/null | sed 's/[[:space:]]*$//' | grep -qx "runtime=$name $rv" && tr -d ' \t\r' < "$f" | grep -qx "socket=$sock" """),
C("container-id is the full ID of container app", 2, r"""
cid=$($K -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].containerID}' 2>/dev/null | sed 's#^[a-z-]*://##')
[ "${#cid}" = 64 ] && tr -d ' \t\r' < "$WS/q108/runtime.txt" 2>/dev/null | grep -qx "container-id=$cid" """),
C("pid is that container's process on the node", 2, r"""
cid=$($K -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].containerID}' 2>/dev/null | sed 's#^[a-z-]*://##')
pid=$(nx "$W2" "crictl inspect -o go-template --template '{{.info.pid}}' $cid 2>/dev/null" </dev/null | tr -d '[:space:]')
[ -n "$cid" ] && [ -n "$pid" ] && tr -d ' \t\r' < "$WS/q108/runtime.txt" 2>/dev/null | grep -qx "pid=$pid" """),
C("log is the container's log file on the node", 1, r"""
cid=$($K -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].containerID}' 2>/dev/null | sed 's#^[a-z-]*://##')
log=$(nx "$W2" "crictl inspect -o go-template --template '{{.status.logPath}}' $cid 2>/dev/null" </dev/null | tr -d '[:space:]')
[ -n "$cid" ] && [ -n "$log" ] && tr -d ' \t\r' < "$WS/q108/runtime.txt" 2>/dev/null | grep -qx "log=$log" """),
],
"solution": """
k -n %{ns} get pod %{pod} -o wide # on {W2}
{SHELL_W2}
crictl version # RuntimeName: containerd RuntimeVersion: v2.x.y
grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml # unix:///run/containerd/containerd.sock
crictl pods --name %{pod} # the Pod sandbox
crictl ps --no-trunc --name app --pod $(crictl pods --name %{pod} -q) # full container ID
crictl inspect <id> | grep -E '"pid"|logPath' # .info.pid and .status.logPath
# or: crictl inspect -o go-template --template '{{.info.pid}} {{.status.logPath}}' <id>
exit
cat > {WS}/q108/runtime.txt <<EOF
runtime=containerd <version>
socket=unix:///run/containerd/containerd.sock
container-id=<64 hex characters>
pid=<number>
log=/var/log/pods/%{ns}_%{pod}_<pod uid>/app/0.log
EOF
# The kubelet drives the runtime over the CRI socket, and crictl speaks the same protocol - which
# is why it still works when the API server does not. `kubectl get pod -o
# jsonpath='{.status.containerStatuses[0].containerID}'` shows the same ID with a containerd://
# prefix; /var/log/pods/... is the file `kubectl logs` reads.
""",
},
{
# drains the control-plane node: CoreDNS moves off it, so never beside the tasks that break DNS
"id": 109, "domain": "ARCH", "title": "Drain the control-plane node and bring it back", "breaks": "coredns",
"params": lambda r: {"ns": ns_pick(r, "maint"), "dep": name_pick(r, "beacon")},
"task": """
The control-plane node `{CP}` is about to be upgraded and has to be emptied first.
1. Drain `{CP}`: it must be unschedulable, and the only Pods left on it are static Pods and Pods
of DaemonSets. Deployment `%{dep}` in Namespace `%{ns}` has a Pod with an `emptyDir` volume on
that node - losing that volume is accepted.
2. While the node is drained, save the output of `kubectl get nodes` to `{WS}/q109/drained.txt`,
and write the phase the Pod of `%{dep}` is in now to `{WS}/q109/phase.txt`.
3. The upgrade is over: make the node schedulable again and wait until `%{dep}` has its Pod back.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q109"; mkdir -p "$WS/q109"
$K uncordon "$CP" >/dev/null 2>&1
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} delete pod -l app=%{dep} --wait=true --grace-period=1 >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}, labels: {app: %{dep}}}
spec:
replicas: 1
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
terminationGracePeriodSeconds: 2
nodeSelector: {kubernetes.io/hostname: "{CP}"}
tolerations:
- {key: node-role.kubernetes.io/control-plane, operator: Exists, effect: NoSchedule}
volumes: [{name: scratch, emptyDir: {}}]
containers:
- name: beacon
image: busybox:1.36
command: [sh, -c, "date > /scratch/started; sleep 86400"]
volumeMounts: [{name: scratch, mountPath: /scratch}]
EOF
$K -n %{ns} rollout status deploy %{dep} --timeout=150s >/dev/null
$K -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[0].metadata.uid}' > "$WS/.q109-uid"
""",
"checks": [
C("drained.txt shows {CP} as SchedulingDisabled", 2, r"""
grep -Eq "^$CP[[:space:]]+Ready,SchedulingDisabled([[:space:]]|\$)" "$WS/q109/drained.txt" 2>/dev/null"""),
C("phase.txt holds Pending", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q109/phase.txt" 2>/dev/null)" Pending"""),
C("the Pod of %{dep} was evicted, and its replacement runs on {CP} again", 2, r"""
old=$(cat "$WS/.q109-uid" 2>/dev/null); [ -n "$old" ] || exit 1
for i in $(seq 1 20); do
new=$($K -n %{ns} get pod -l app=%{dep} -o jsonpath='{range .items[?(@.status.phase=="Running")]}{.metadata.uid} {.spec.nodeName}{"\n"}{end}' 2>/dev/null | head -1)
[ -n "$new" ] && break; sleep 3
done
[ -n "$new" ] && [ "${new%% *}" != "$old" ] && [ "${new##* }" = "$CP" ]"""),
C("{CP} is schedulable again and CoreDNS is fully available", 2, r"""
old=$(cat "$WS/.q109-uid" 2>/dev/null); [ -n "$old" ] || exit 1
$K -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[*].metadata.uid}' 2>/dev/null | grep -q "$old" && exit 1
[ "$(j node "$CP" '{.spec.unschedulable}')" != true ] || exit 1
for i in $(seq 1 20); do
[ "$(j -n kube-system deploy coredns '{.status.availableReplicas}')" = "$(j -n kube-system deploy coredns '{.spec.replicas}')" ] && exit 0; sleep 3
done; exit 1"""),
],
"solution": """
k drain {CP} --ignore-daemonsets --delete-emptydir-data
# without the flags it stops at: cannot delete DaemonSet-managed Pods ... / cannot delete Pods with local storage ...
k get nodes > {WS}/q109/drained.txt # {CP} Ready,SchedulingDisabled control-plane
k -n %{ns} get pod -l app=%{dep} # Pending: it may only run on {CP}, and that node is cordoned
echo Pending > {WS}/q109/phase.txt
k uncordon {CP}
k -n %{ns} rollout status deploy %{dep}
k get nodes; k -n kube-system get deploy coredns # CoreDNS moved to the workers during the drain and stays there
# drain = cordon + evict. It leaves DaemonSet Pods (they would come straight back) and static Pods,
# refuses Pods with emptyDir data until you accept the loss, and refuses bare Pods until --force -
# which deletes them for good. The node stays cordoned until you uncordon it: a real upgrade
# (kubeadm upgrade apply, then the kubelet package) happens between those two commands.
""",
},
{
"id": 110, "domain": "ARCH", "title": "Prepare the join of a second control-plane node",
"task": """
The cluster is getting a second control-plane node. Prepare what the new machine needs - on the
control-plane node, `{SHELL_CP}` - without joining anything.
1. Write the address every node uses to reach the API server - the cluster's control-plane
endpoint, as kubeadm has it configured - to `{WS}/q110/endpoint.txt`.
2. Upload the control-plane certificates to the cluster, so that a joining control-plane node can
fetch them.
3. Save the complete `kubeadm join` command a new **control-plane** node has to run - with a new
token, the CA certificate hash and the certificate key from step 2 - to
`{WS}/q110/join-control-plane.sh`, on one line.
""",
"setup": r"""
rm -rf "$WS/q110"; mkdir -p "$WS/q110"
$K -n kube-system delete secret kubeadm-certs --ignore-not-found >/dev/null 2>&1
date +%s > "$WS/.q110-t0"
""",
"checks": [
C("endpoint.txt holds the control-plane endpoint", 1, r"""
want=$($K -n kube-system get cm kubeadm-config -o jsonpath='{.data.ClusterConfiguration}' 2>/dev/null | awk '/^controlPlaneEndpoint:/{print $2}' | tr -d '"')
[ -n "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q110/endpoint.txt" 2>/dev/null)" "$want" """),
C("the control-plane certificates were uploaded (Secret kubeadm-certs)", 2, r"""
ct=$(j -n kube-system secret kubeadm-certs '{.metadata.creationTimestamp}'); [ -n "$ct" ] || exit 1
[ "$(date -d "$ct" +%s)" -ge "$(( $(cat "$WS/.q110-t0" 2>/dev/null || echo 9999999999) - 5 ))" ]"""),
C("join command for a control-plane node: endpoint, CA hash, --control-plane, a certificate key", 2, r"""
f="$WS/q110/join-control-plane.sh"
ep=$($K -n kube-system get cm kubeadm-config -o jsonpath='{.data.ClusterConfiguration}' 2>/dev/null | awk '/^controlPlaneEndpoint:/{print $2}' | tr -d '"')
hash=$(nx "$CP" "openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex | sed 's/^.* //'" </dev/null | tr -d '[:space:]')
[ -n "$ep" ] && [ -n "$hash" ] || exit 1
l=$(grep 'kubeadm join' "$f" 2>/dev/null | head -1)
echo "$l" | grep -q "kubeadm join $ep " && echo "$l" | grep -q -- "--discovery-token-ca-cert-hash sha256:$hash" \
&& echo "$l" | grep -Eq -- '--control-plane( |$)' && echo "$l" | grep -Eq -- '--certificate-key [0-9a-f]{64}( |$)' """),
C("its token is a real, unexpired bootstrap token", 2, r"""
tok=$(grep -oE -- '--token [a-z0-9]{6}\.[a-z0-9]{16}' "$WS/q110/join-control-plane.sh" 2>/dev/null | head -1 | awk '{print $2}'); [ -n "$tok" ] || exit 1
exp=$(j -n kube-system secret bootstrap-token-${tok%%.*} '{.data.expiration}' | base64 -d 2>/dev/null); [ -n "$exp" ] || exit 1
[ "$(date -d "$exp" +%s)" -gt "$(date +%s)" ]"""),
],
"solution": """
k -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint # {CP}:6443
echo {CP}:6443 > {WS}/q110/endpoint.txt
{SHELL_CP}
kubeadm init phase upload-certs --upload-certs
# [upload-certs] Storing the certificates in Secret "kubeadm-certs" in the "kube-system" Namespace
# [upload-certs] Using certificate key:
# <64 hex characters>
kubeadm token create --print-join-command --certificate-key <that key>
# kubeadm join {CP}:6443 --token ... --discovery-token-ca-cert-hash sha256:... --control-plane --certificate-key ...
exit
# paste that line into {WS}/q110/join-control-plane.sh
# A control-plane node needs the cluster's CA, front-proxy CA, etcd CA and service-account keys.
# upload-certs puts them, encrypted with the certificate key, into the Secret kubeadm-certs for
# two hours; `kubeadm join --control-plane --certificate-key` downloads and decrypts them. All of
# it only works because the cluster was initialised with a controlPlaneEndpoint - the one stable
# address (a load balancer or VIP in a real HA cluster) that every node and kubeconfig points at.
""",
},
{
"id": 111, "domain": "ARCH", "title": "Who leads, and who is in etcd",
"task": """
Before a second control-plane node is added, record how the control plane is laid out today.
Write `{WS}/q111/ha.txt` in exactly this format:
```
control-plane-nodes=<number of control-plane nodes>
etcd-topology=<stacked or external>
etcd-members=<number of members of the etcd cluster>
etcd-leader=<name of the etcd member that is the leader>
scheduler-leader=<name of the node whose kube-scheduler holds the leader lease>
controller-manager-leader=<name of the node whose kube-controller-manager holds the leader lease>
```
`stacked` means etcd runs on the control-plane nodes themselves, `external` that the API server
uses etcd hosts of their own. `etcdctl` is on the control-plane node (`{SHELL_CP}`); etcd's
certificates are under `/etc/kubernetes/pki/etcd`.
""",
"setup": r"""
rm -rf "$WS/q111"; mkdir -p "$WS/q111"
ensure_etcd_tools >/dev/null 2>&1 || { echo "etcdctl could not be installed on $CP - run ./etcd-tools.sh"; exit 1; }
""",
"checks": [
C("control-plane-nodes and etcd-topology", 2, r"""
n=$($K get nodes -l node-role.kubernetes.io/control-plane --no-headers 2>/dev/null | grep -c .)
topo=external; [ "$($K -n kube-system get pods -l component=etcd,tier=control-plane --no-headers 2>/dev/null | grep -c .)" -ge 1 ] && topo=stacked
f=$(tr -d ' \t\r' < "$WS/q111/ha.txt" 2>/dev/null)
[ "$n" -ge 1 ] && echo "$f" | grep -qx "control-plane-nodes=$n" && echo "$f" | grep -qx "etcd-topology=$topo" """),
C("etcd-members and etcd-leader", 3, r"""
E='""" + ETCDCTL + r"""'
out=$( { nx "$CP" "$E endpoint status -w json 2>/dev/null" </dev/null; echo; nx "$CP" "$E member list -w json 2>/dev/null" </dev/null; } | python3 -c '
import sys, json
docs = [json.loads(l) for l in sys.stdin.read().splitlines() if l.strip()[:1] in "[{" and l.strip()]
status, members = docs[0], docs[1]["members"]
leader = status[0]["Status"]["leader"]
print(len(members), next(m["name"] for m in members if m["ID"] == leader))' 2>/dev/null)
[ -n "$out" ] || exit 1
f=$(tr -d ' \t\r' < "$WS/q111/ha.txt" 2>/dev/null)
echo "$f" | grep -qx "etcd-members=${out%% *}" && echo "$f" | grep -qx "etcd-leader=${out##* }" """),
C("scheduler-leader and controller-manager-leader", 2, r"""
s=$(j -n kube-system lease kube-scheduler '{.spec.holderIdentity}' | cut -d_ -f1)
c=$(j -n kube-system lease kube-controller-manager '{.spec.holderIdentity}' | cut -d_ -f1)
f=$(tr -d ' \t\r' < "$WS/q111/ha.txt" 2>/dev/null)
[ -n "$s" ] && [ -n "$c" ] && echo "$f" | grep -qx "scheduler-leader=$s" && echo "$f" | grep -qx "controller-manager-leader=$c" """),
],
"solution": """
k get nodes -l node-role.kubernetes.io/control-plane # how many
k -n kube-system get pods -l component=etcd -o wide # an etcd static Pod on the control-plane node = stacked
k -n kube-system get lease kube-scheduler kube-controller-manager # HOLDER is <node>_<uuid>
{SHELL_CP}
export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379 ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt \\
ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key
etcdctl member list -w table # one line per member: ID, NAME, peer and client URLs
etcdctl endpoint status -w table # IS LEADER (add --cluster to ask every member)
exit
cat > {WS}/q111/ha.txt <<EOF
control-plane-nodes=1
etcd-topology=stacked
etcd-members=1
etcd-leader={CP}
scheduler-leader={CP}
controller-manager-leader={CP}
EOF
# In an HA control plane every kube-apiserver serves at once, but only one kube-scheduler and one
# kube-controller-manager act: they elect a leader through a Lease in kube-system, and the others
# wait. etcd elects its own leader and needs a majority of members to stay writable - three
# members survive the loss of one, five of two, and an even number adds nothing.
""",
},
{
"id": 112, "domain": "ARCH", "title": "CNI, CSI and CRI: what this cluster plugs in",
"task": """
Kubernetes leaves networking, storage and running containers to plugins. Record which ones this
cluster uses in `{WS}/q112/interfaces.txt`, in exactly this format:
```
cni-config=<full path of the CNI configuration file on node {W1}>
cni-plugins=<the type of every plugin in that file's chain, in order, separated by commas>
cni-ipam=<the type of the IPAM plugin it uses>
cri-socket=<the runtime endpoint the kubelet on {W1} is configured with>
csi-drivers=<number of CSIDriver objects in the cluster>
default-provisioner=<provisioner of the default StorageClass>
```
Reach the node with `{SHELL_W1}`.
""",
"setup": r"""
rm -rf "$WS/q112"; mkdir -p "$WS/q112"
""",
"checks": [
C("cni-config, cni-plugins and cni-ipam", 3, r"""
conf=$(nx "$W1" "ls /etc/cni/net.d/*.conflist /etc/cni/net.d/*.conf 2>/dev/null | sort | head -1" </dev/null | tr -d '[:space:]'); [ -n "$conf" ] || exit 1
out=$(nx "$W1" "cat $conf" </dev/null | python3 -c '
import sys, json
d = json.load(sys.stdin); p = d.get("plugins", [d])
print(",".join(x["type"] for x in p), next((x["ipam"]["type"] for x in p if "ipam" in x), ""))' 2>/dev/null)
[ -n "$out" ] || exit 1
f=$(tr -d ' \t\r' < "$WS/q112/interfaces.txt" 2>/dev/null)
echo "$f" | grep -qx "cni-config=$conf" && echo "$f" | grep -qx "cni-plugins=${out%% *}" && echo "$f" | grep -qx "cni-ipam=${out##* }" """),
C("cri-socket", 2, r"""
sock=$(nx "$W1" "awk '/^containerRuntimeEndpoint:/{print \$2}' /var/lib/kubelet/config.yaml" </dev/null | tr -d '"[:space:]')
[ -n "$sock" ] && tr -d ' \t\r' < "$WS/q112/interfaces.txt" 2>/dev/null | grep -qx "cri-socket=$sock" """),
C("csi-drivers and default-provisioner", 2, r"""
n=$($K get csidrivers --no-headers 2>/dev/null | grep -c .)
prov=$($K get sc -o jsonpath='{range .items[?(@.metadata.annotations.storageclass\.kubernetes\.io/is-default-class=="true")]}{.provisioner}{"\n"}{end}' 2>/dev/null | head -1)
[ -n "$prov" ] || exit 1
f=$(tr -d ' \t\r' < "$WS/q112/interfaces.txt" 2>/dev/null)
echo "$f" | grep -qx "csi-drivers=$n" && echo "$f" | grep -qx "default-provisioner=$prov" """),
],
"solution": """
{SHELL_W1}
ls /etc/cni/net.d/ # 10-kindnet.conflist - the kubelet's runtime takes the first file
cat /etc/cni/net.d/10-kindnet.conflist # "plugins": [{"type": "ptp", "ipam": {"type": "host-local"}}, {"type": "portmap"}]
ls /opt/cni/bin # the plugin binaries those types name
grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml
exit
k get csidrivers # No resources found = 0
k get sc # the (default) class and its PROVISIONER
cat > {WS}/q112/interfaces.txt <<EOF
cni-config=/etc/cni/net.d/10-kindnet.conflist
cni-plugins=ptp,portmap
cni-ipam=host-local
cri-socket=unix:///run/containerd/containerd.sock
csi-drivers=0
default-provisioner=rancher.io/local-path
EOF
# CRI: the kubelet talks to the runtime over that socket (crictl uses the same one). CNI: the
# runtime calls the plugins of the chain in order for every Pod sandbox - here ptp wires the veth
# pair, host-local hands out an address from the node's Pod CIDR, portmap serves hostPorts.
# CSI: a storage driver registers a CSIDriver object and a StorageClass names it as provisioner;
# this cluster's local-path provisioner is not a CSI driver, hence none.
""",
},
# ------------------------------------------------------------------- NET ----
{
"id": 113, "domain": "NET", "title": "Pod to Pod, across nodes",
"params": lambda r: {"ns": ns_pick(r, "wire"), "web": name_pick(r, "web"), "client": name_pick(r, "client"), "edge": name_pick(r, "edge")},
"task": """
Namespace `%{ns}` runs a few Pods on different nodes. No Service is involved in this task.
1. Write the IP address of Pod `%{web}` and the node it runs on to `{WS}/q113/web.txt`, as
`<ip> <node>`.
2. From Pod `%{client}`, which runs on another node, request `http://<that IP>` directly and save
the response to `{WS}/q113/direct.html`.
3. Write the Pod CIDR of every node of the cluster to `{WS}/q113/pod-cidrs.txt`, one node per
line as `<node> <cidr>`.
4. One Pod in `%{ns}` has an address that lies in none of those ranges. Write its name to
`{WS}/q113/outside.txt` and the Pod spec field that causes it to `{WS}/q113/why.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q113"; mkdir -p "$WS/q113"
$K -n %{ns} delete pod --all --wait=true --grace-period=1 >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: %{web}, namespace: %{ns}, labels: {app: web}}
spec:
nodeSelector: {kubernetes.io/hostname: "{W1}"}
containers: [{name: web, image: "nginx:1.27", ports: [{containerPort: 80}]}]
---
apiVersion: v1
kind: Pod
metadata: {name: %{client}, namespace: %{ns}, labels: {app: client}}
spec:
terminationGracePeriodSeconds: 1
nodeSelector: {kubernetes.io/hostname: "{W2}"}
containers: [{name: client, image: "busybox:1.36", command: [sleep, "86400"]}]
---
apiVersion: v1
kind: Pod
metadata: {name: %{edge}, namespace: %{ns}, labels: {app: edge}}
spec:
terminationGracePeriodSeconds: 1
hostNetwork: true
nodeSelector: {kubernetes.io/hostname: "{W3}"}
containers: [{name: edge, image: "busybox:1.36", command: [sleep, "86400"]}]
---
apiVersion: v1
kind: Pod
metadata: {name: spare-%{edge}, namespace: %{ns}, labels: {app: spare}}
spec:
terminationGracePeriodSeconds: 1
nodeSelector: {kubernetes.io/hostname: "{W3}"}
containers: [{name: spare, image: "busybox:1.36", command: [sleep, "86400"]}]
EOF
$K -n %{ns} wait --for=condition=Ready pod --all --timeout=150s >/dev/null
""",
"checks": [
C("web.txt holds the Pod's IP and node", 1, r"""
want=$(j -n %{ns} pod %{web} '{.status.podIP} {.spec.nodeName}')
[ -n "$want" ] && eq "$(tr -s ' \t' ' ' < "$WS/q113/web.txt" 2>/dev/null | tr -d '\r' | sed 's/^ *//; s/ *$//' | grep .)" "$want" """),
C("direct.html holds the page fetched from the Pod's IP", 2, r"""grep -q 'Welcome to nginx' "$WS/q113/direct.html" 2>/dev/null"""),
C("pod-cidrs.txt lists every node with its Pod CIDR", 2, r"""
want=$($K get nodes -o jsonpath='{range .items[*]}{.metadata.name} {.spec.podCIDR}{"\n"}{end}' 2>/dev/null | sort)
[ "$(echo "$want" | grep -c .)" -ge 2 ] || exit 1
eq "$(tr -s ' \t' ' ' < "$WS/q113/pod-cidrs.txt" 2>/dev/null | tr -d '\r' | sed 's/^ *//; s/ *$//' | grep . | sort)" "$want" """),
C("outside.txt names %{edge}, why.txt names hostNetwork", 2, r"""
eq "$(tr -d '[:space:]' < "$WS/q113/outside.txt" 2>/dev/null | sed 's#^pod/##')" %{edge} && grep -qi 'hostNetwork' "$WS/q113/why.txt" 2>/dev/null"""),
],
"solution": """
k -n %{ns} get pods -o wide # IP and NODE of every Pod
k -n %{ns} get pod %{web} -o jsonpath='{.status.podIP} {.spec.nodeName}' > {WS}/q113/web.txt
k -n %{ns} exec %{client} -- wget -qO- -T3 http://$(cut -d' ' -f1 {WS}/q113/web.txt) > {WS}/q113/direct.html
k get nodes -o jsonpath='{range .items[*]}{.metadata.name} {.spec.podCIDR}{"\\n"}{end}' > {WS}/q113/pod-cidrs.txt
# %{edge}'s IP is its node's own address (compare with `k get nodes -o wide`), not one from 10.244.x.0/24
echo %{edge} > {WS}/q113/outside.txt
k -n %{ns} get pod %{edge} -o yaml | grep hostNetwork
echo hostNetwork > {WS}/q113/why.txt
# Every Pod gets its own address from its node's Pod CIDR, and any Pod reaches any other Pod on
# any node at that address without NAT - that is the whole network model, and the CNI plugin is
# what makes it true. A hostNetwork Pod opts out: it lives in the node's network namespace and
# shares its address and ports. Pod addresses change with every new Pod, which is why Services exist.
""",
},
]

View File

@ -1,840 +0,0 @@
"""CKA curriculum as a study path: the 27 objectives of the five domains, in the curriculum's own
words and order (CKA_Curriculum_v1.35.pdf, cncf/curriculum), each with what to know, the fastest
commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it.
./exam objectives the path, with your progress per objective
./exam notes 4.4 the notes for one objective (id or key: 4.4, lifecycle)
./exam study 4.4 those notes, then its tasks with no clock and solutions visible
./exam drill 4.4 the same tasks against the clock
./exam next whatever is next on the path
"tasks" lists every task that really exercises the objective - the plain pool tasks first, then
the hard ones, then those of the fixed simulator sets (68-85 Set C, 86-102 Set D), which stay out
of objective sessions unless you ask for them (--all). A task may sit under several objectives.
Every task must be listed at least once and every objective needs two pool tasks -
`./exam objectives` says so when that stops being true (tasks 103-113 exist because of that rule).
An objective with many tasks takes several sittings: each one brings up to five tasks you have not
solved yet, and never two that break the same part of the cluster.
"""
# The path walks the domains in this order, not the curriculum's: Troubleshooting is 30% of the
# exam and every one of its tasks assumes you already know how the healthy thing is built.
STUDY_ORDER = ["WORK", "NET", "STOR", "ARCH", "TSHOOT"]
OBJECTIVES = [
# ------------------------------------------------------------------- 1. Storage (10%) ----
{
"id": "1.1", "key": "storageclasses", "domain": "STOR",
"text": "Implement storage classes and dynamic volume provisioning",
"tasks": [16, 36, 59, 99],
"know": [
"A StorageClass names a provisioner and its settings. A claim that asks for the class gets a PersistentVolume made for it - that is dynamic provisioning.",
"Fields: provisioner, reclaimPolicy (Delete by default, or Retain), volumeBindingMode (Immediate or WaitForFirstConsumer), allowVolumeExpansion, parameters.",
"The default class carries the annotation storageclass.kubernetes.io/is-default-class: \"true\"; a claim with no storageClassName gets it. `storageClassName: \"\"` asks for no class at all.",
"A StatefulSet's volumeClaimTemplates make one claim per replica (data-web-0, data-web-1 ...).",
],
"fast": """
k get sc # (default) marks the default class
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: {name: fast-local}
provisioner: rancher.io/local-path
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer
---
k patch sc standard -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
k patch sc fast-local -p '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
k get pvc,pv; k describe pvc data # the Events say why a claim is Pending
""",
"traps": [
"With WaitForFirstConsumer a claim stays Pending until a Pod uses it. That is not an error.",
"Leave exactly one class marked default: take the annotation off the old one as well as putting it on the new one.",
"provisioner, reclaimPolicy and volumeBindingMode cannot be edited - delete the class and create it again.",
],
"docs": ["https://kubernetes.io/docs/concepts/storage/storage-classes/",
"https://kubernetes.io/docs/concepts/storage/dynamic-provisioning/",
"https://kubernetes.io/docs/tasks/administer-cluster/change-default-storage-class/"],
},
{
"id": "1.2", "key": "volumes", "domain": "STOR",
"text": "Configure volume types, access modes and reclaim policies",
"tasks": [15, 35, 58, 68, 72, 102],
"know": [
"Volume types you meet: hostPath (a directory of whatever node the Pod lands on), local (a disk of one node, needs nodeAffinity), emptyDir, nfs, csi, and the configMap / secret / projected volumes.",
"Access modes: ReadWriteOnce (one node), ReadOnlyMany, ReadWriteMany, ReadWriteOncePod (one Pod). A claim binds only to a volume that offers its mode.",
"The reclaim policy is on the PersistentVolume: Retain keeps the volume and its data when the claim goes (it becomes Released), Delete removes both.",
],
"fast": """
apiVersion: v1
kind: PersistentVolume
metadata: {name: pv-data}
spec:
capacity: {storage: 1Gi}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: {path: /mnt/data}
---
k patch pv pv-data -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
k patch pv pv-data --type=json -p '[{"op":"remove","path":"/spec/claimRef"}]' # Released -> Available
k get pv -o custom-columns=NAME:.metadata.name,MODES:.spec.accessModes,RECLAIM:.spec.persistentVolumeReclaimPolicy,STATUS:.status.phase,CLAIM:.spec.claimRef.name
""",
"traps": [
"A Released volume never binds again by itself: remove its claimRef first.",
"A `local` volume needs spec.nodeAffinity; a hostPath volume does not pin the Pod to the node that has the data.",
"The local-path provisioner of this cluster only does ReadWriteOnce and ReadWriteOncePod - a ReadWriteMany claim stays Pending.",
],
"docs": ["https://kubernetes.io/docs/concepts/storage/persistent-volumes/",
"https://kubernetes.io/docs/concepts/storage/volumes/",
"https://kubernetes.io/docs/tasks/administer-cluster/change-pv-reclaim-policy/"],
},
{
"id": "1.3", "key": "claims", "domain": "STOR",
"text": "Manage persistent volumes and persistent volume claims",
"tasks": [15, 36, 35, 58, 59, 68, 72, 81, 99, 102],
"know": [
"A claim binds to one volume, and only if class, access mode and size fit (the volume may be larger); `volumeName` or a selector narrows it to one volume.",
"Phases: a volume goes Available, Bound, Released; a claim Pending, Bound.",
"A Pod mounts a claim of its own Namespace: volumes[].persistentVolumeClaim.claimName, then volumeMounts.",
"A claim in use is protected: deleting it waits (Terminating) until no Pod references it, finished Pods included.",
],
"fast": """
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: pvc-data, namespace: storage}
spec:
accessModes: [ReadWriteOnce]
storageClassName: manual # omit for the default class
resources: {requests: {storage: 500Mi}}
--- # in the Pod:
volumes: [{name: data, persistentVolumeClaim: {claimName: pvc-data}}]
containers: [{..., volumeMounts: [{name: data, mountPath: /data}]}]
k get pvc,pv -A; k describe pvc pvc-data # Used By: and Events:
k describe pod POD | tail # unbound claim -> the Pod stays Pending
""",
"traps": [
"There is no `kubectl create pvc` or `create pv` - copy both from the docs.",
"For a hand-made volume the claim's storageClassName has to equal the volume's exactly; leaving it out means the default class, not none.",
"Apart from growing the request (and only if the class allows expansion) a claim cannot be edited.",
"To move data between claims, run a Pod or Job that mounts both.",
],
"docs": ["https://kubernetes.io/docs/concepts/storage/persistent-volumes/",
"https://kubernetes.io/docs/tutorials/configuration/configure-persistent-volume-storage/"],
},
# ------------------------------------------------------------ 2. Troubleshooting (30%) ----
{
"id": "2.1", "key": "nodes", "domain": "TSHOOT",
"text": "Troubleshoot clusters and nodes",
"tasks": [17, 39, 60, 92, 90, 95],
"know": [
"Start from the cluster: `k get nodes`, then `k describe node` - Conditions, Taints, Unschedulable, Events.",
"A NotReady node is nearly always its kubelet: on the node, `systemctl status kubelet` and `journalctl -u kubelet` name the cause.",
"What the kubelet needs: its unit and drop-in (which binary, which flags), /var/lib/kubelet/config.yaml, /etc/kubernetes/kubelet.conf (its kubeconfig), its certificates in /var/lib/kubelet/pki, and a running container runtime.",
"Ready but SchedulingDisabled is a cordon, not a fault: `k uncordon`.",
],
"fast": """
k get nodes -o wide; k describe node NODE | sed -n '/Conditions/,/Addresses/p'
ssh NODE
systemctl status kubelet; journalctl -u kubelet --no-pager | tail -30
systemctl cat kubelet # the unit and its drop-ins: ExecStart, EnvironmentFile
cat /var/lib/kubelet/config.yaml; cat /var/lib/kubelet/kubeadm-flags.env
systemctl daemon-reload; systemctl enable --now kubelet; systemctl restart kubelet
systemctl status containerd; crictl ps -a
k uncordon NODE; k describe node NODE | grep -i taint
""",
"traps": [
"A kubelet that was stopped is often disabled too: `enable --now`, or it is gone again after a reboot.",
"After changing a unit file nothing happens until `systemctl daemon-reload`.",
"There may be more than one fault. After every fix read the journal again instead of assuming you are done.",
],
"docs": ["https://kubernetes.io/docs/tasks/debug/debug-cluster/",
"https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/",
"https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/"],
},
{
"id": "2.2", "key": "components", "domain": "TSHOOT",
"text": "Troubleshoot cluster components",
"tasks": [20, 38, 61, 66, 97, 86],
"know": [
"kube-apiserver, kube-scheduler, kube-controller-manager and etcd run as static Pods: the kubelet starts whatever is in /etc/kubernetes/manifests and restarts it when the file changes.",
"Read the symptom: Pods stay Pending with no events - scheduler. Deployments make no ReplicaSet or Pods - controller-manager. kubectl is refused - API server (or etcd behind it).",
"With the API server up: `k -n kube-system get pods`, `logs`, `describe`. With it down: `crictl ps -a`, `crictl logs`, /var/log/pods and the kubelet's journal on the node.",
],
"fast": """
k -n kube-system get pods; k -n kube-system logs kube-scheduler-NODE; k -n kube-system describe pod kube-scheduler-NODE
ssh CONTROL-PLANE
ls /etc/kubernetes/manifests; vi /etc/kubernetes/manifests/kube-scheduler.yaml
crictl ps -a | grep -E 'kube-|etcd'; crictl logs CONTAINER-ID
ls /var/log/pods/kube-system_kube-apiserver-*/kube-apiserver/ # its logs when kubectl cannot ask for them
journalctl -u kubelet --no-pager | grep -i -E 'manifest|apiserver' | tail
""",
"traps": [
"The faults are small: a misspelt image, binary or flag, a wrong path in a volume or certificate flag, a wrong port in a probe.",
"Do not leave a backup copy inside the manifests directory - the kubelet runs every file there.",
"After saving a manifest give the kubelet up to a minute before deciding the fix did not work.",
],
"docs": ["https://kubernetes.io/docs/tasks/debug/debug-cluster/",
"https://kubernetes.io/docs/concepts/overview/components/",
"https://kubernetes.io/docs/tasks/configure-pod-container/static-pod/"],
},
{
"id": "2.3", "key": "usage", "domain": "TSHOOT",
"text": "Monitor cluster and application resource usage",
"tasks": [103, 23, 53, 85, 89],
"know": [
"`kubectl top node|pod` shows live CPU and memory from metrics-server through the metrics API (metrics.k8s.io); `--containers` splits a Pod up, `--sort-by=cpu|memory` orders it.",
"`k describe node` shows what is allocated - the sum of requests and limits - which is not what is used.",
"`kubectl get` sorts and shapes everything else: --sort-by, -o custom-columns, -o jsonpath, -A.",
],
"fast": """
k top node; k top pod -A --sort-by=memory | head; k top pod -n NS --containers
k describe node NODE | grep -A8 'Allocated resources'
k get pods -A --sort-by='.status.containerStatuses[0].restartCount'
k -n kube-system get deploy metrics-server; k get apiservice v1beta1.metrics.k8s.io
k -n kube-system logs deploy/metrics-server
""",
"traps": [
"`Metrics API not available` means metrics-server is missing or not Ready (on this cluster it needs --kubelet-insecure-tls).",
"`kubectl top --sort-by` puts the largest first; `kubectl get --sort-by` puts it last.",
"A new Pod has no metrics for about a minute.",
],
"docs": ["https://kubernetes.io/docs/tasks/debug/debug-cluster/resource-metrics-pipeline/",
"https://kubernetes.io/docs/tasks/debug/debug-cluster/resource-usage-monitoring/",
"https://kubernetes.io/docs/reference/kubectl/quick-reference/"],
},
{
"id": "2.4", "key": "logs", "domain": "TSHOOT",
"text": "Manage and evaluate container output streams",
"tasks": [19, 104, 8, 85],
"know": [
"`kubectl logs` shows what a container wrote to stdout and stderr. The runtime keeps it on the node under /var/log/pods/NAMESPACE_POD_UID/CONTAINER/, and it goes when the Pod is deleted.",
"Pick the container with -c (init containers too) or take --all-containers; --previous is the run before the last restart.",
"Narrow with --tail=N, --since=10m, --timestamps; follow with -f; take every Pod of a label with -l and tell them apart with --prefix.",
"On a node, without the API server: `crictl logs CONTAINER-ID`.",
],
"fast": """
k logs POD -c CONTAINER # --all-containers --prefix
k logs POD --previous # the crashed run
k logs POD --tail=50 --since=10m --timestamps
k logs -l app=web --prefix --tail=5 # every Pod with the label
k logs deploy/web; k logs job/render # one Pod of it
k logs POD | grep ERROR > errors.log
crictl ps -a; crictl logs --tail=20 CONTAINER-ID; ls /var/log/pods/
""",
"traps": [
"With -l, only the last 10 lines of each Pod are shown unless you set --tail (--tail=-1 for everything).",
"`kubectl logs deploy/x` reads one Pod, not all replicas.",
"--previous fails when the container has never restarted.",
],
"docs": ["https://kubernetes.io/docs/concepts/cluster-administration/logging/",
"https://kubernetes.io/docs/reference/kubectl/quick-reference/",
"https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/"],
},
{
"id": "2.5", "key": "netdebug", "domain": "TSHOOT",
"text": "Troubleshoot services and networking",
"tasks": [18, 62, 37, 57, 73, 101, 70, 90],
"know": [
"Work outwards from the Pod: is it Ready, does the Service have endpoints (selector against Pod labels), is targetPort the port the container listens on, does the name resolve, does a NetworkPolicy stand in the way.",
"DNS is a Service like any other: the CoreDNS Pods, the kube-dns Service and its endpoints, the Corefile in ConfigMap coredns.",
"Underneath: kube-proxy (a DaemonSet) turns Services into rules on every node, and the CNI plugin connects the Pods.",
],
"fast": """
k get svc,endpointslices -o wide; k describe svc SVC # Endpoints: <none> = nothing selected
k get pods -l KEY=VALUE --show-labels -o wide
k run tmp --rm -it --restart=Never --image=busybox:1.36 -- sh # wget -qO- -T3 http://SVC:PORT ; nslookup SVC.NS.svc.cluster.local
k -n kube-system get pods -l k8s-app=kube-dns -o wide; k -n kube-system logs -l k8s-app=kube-dns
k -n kube-system get cm coredns -o yaml; k -n kube-system get svc,endpointslices -l k8s-app=kube-dns
k -n kube-system get ds kube-proxy; k -n kube-system logs ds/kube-proxy
k get netpol -A
""",
"traps": [
"A Service with no endpoints still resolves - it is the connection that fails.",
"Test names with the fully qualified form (SVC.NS.svc.cluster.local): busybox's nslookup misreports short ones.",
"A NetworkPolicy in kube-system can cut DNS off for the whole cluster.",
"Fix what is broken and leave the rest alone - the graders check what you did not have to touch.",
],
"docs": ["https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/",
"https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/"],
},
# ------------------------------------------------- 3. Workloads & Scheduling (15%) ----
{
"id": "3.1", "key": "rollouts", "domain": "WORK",
"text": "Understand application deployments and how to perform rolling update and rollbacks",
"tasks": [7, 51],
"know": [
"Any change to `spec.template` starts a rollout: a new ReplicaSet scales up while the old one scales down, within maxSurge and maxUnavailable (25% each by default).",
"Every rollout is a revision. `kubectl rollout`: status, history, undo, pause, resume, restart.",
"minReadySeconds, revisionHistoryLimit and progressDeadlineSeconds tune how careful and how patient a rollout is.",
],
"fast": """
k set image deploy/web nginx=nginx:1.27 # CONTAINER=IMAGE
k scale deploy/web --replicas=4
k rollout status deploy/web
k rollout history deploy/web; k rollout history deploy/web --revision=2 # that revision's template
k rollout undo deploy/web --to-revision=2
k rollout pause deploy/web; k rollout resume deploy/web
k patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}'
""",
"traps": [
"To go back to a particular image, look at each revision's template first - \"the previous one\" is not always the one asked for.",
"An undo makes a new revision out of the old one, so the numbers keep climbing.",
"A paused Deployment records changes but rolls nothing out until it is resumed.",
"maxSurge and maxUnavailable cannot both be 0.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"],
},
{
"id": "3.2", "key": "config", "domain": "WORK",
"text": "Use ConfigMaps and Secrets to configure applications",
"tasks": [10, 29, 79, 93, 84],
"know": [
"Both hold keys. A key becomes an environment variable (configMapKeyRef / secretKeyRef, or every key at once with envFrom) or a file (a configMap / secret volume; `items` picks and renames keys).",
"Secret data is base64 - an encoding, not encryption. Types: Opaque, kubernetes.io/tls, kubernetes.io/dockerconfigjson.",
"Mounted files follow a change after a short delay. Environment variables and subPath mounts never do: restart the Pods.",
"`immutable: true` freezes one for good. To change the content, make a new object and point the workload at it.",
],
"fast": """
k create cm app-config --from-literal=APP_MODE=production --from-file=app.properties
k create secret generic db-creds --from-literal=username=admin --from-literal=password='S3cr3t!'
k create secret tls web-tls --cert=tls.crt --key=tls.key
k get secret db-creds -o jsonpath='{.data.password}' | base64 -d
env:
- {name: APP_MODE, valueFrom: {configMapKeyRef: {name: app-config, key: APP_MODE}}}
- {name: DB_PASS, valueFrom: {secretKeyRef: {name: db-creds, key: password}}}
envFrom: [{configMapRef: {name: app-config}}, {secretRef: {name: db-creds}}]
volumes: [{name: creds, secret: {secretName: db-creds, defaultMode: 0400}}]
k set env deploy/web --from=configmap/app-config; k rollout restart deploy/web
""",
"traps": [
"A secret volume names it with `secretName`; a configMap volume uses `name`.",
"A Pod that references a missing ConfigMap, Secret or key waits in CreateContainerConfigError.",
"`--from-file` makes one key named after the file; `--from-env-file` makes one key per line.",
],
"docs": ["https://kubernetes.io/docs/concepts/configuration/configmap/",
"https://kubernetes.io/docs/concepts/configuration/secret/",
"https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/"],
},
{
"id": "3.3", "key": "autoscaling", "domain": "WORK",
"text": "Configure workload autoscaling",
"tasks": [105, 23, 82],
"know": [
"A HorizontalPodAutoscaler (autoscaling/v2) keeps a workload between minReplicas and maxReplicas by a metric - usually average CPU or memory utilisation, which is usage divided by the container's request.",
"It reads the metrics API, so it needs metrics-server, and the containers need a request for the resource it scales on.",
"`behavior` shapes the change: scaleUp and scaleDown each take a stabilizationWindowSeconds and policies (so many Pods or Percent per periodSeconds).",
],
"fast": """
k set resources deploy web --requests=cpu=100m
k autoscale deploy web --min=2 --max=6 --cpu=60% # kubectl before 1.34: --cpu-percent=60
k get hpa; k describe hpa web
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: {name: web}
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: web}
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource: {name: cpu, target: {type: Utilization, averageUtilization: 60}}
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies: [{type: Pods, value: 1, periodSeconds: 60}]
""",
"traps": [
"TARGETS `<unknown>`: no metrics-server, or no request on the containers.",
"`kubectl autoscale` cannot write `behavior` - edit the object or write the YAML.",
"minReplicas is enforced at once, even without metrics.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/",
"https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale-walkthrough/",
"https://kubernetes.io/docs/concepts/workloads/autoscaling/"],
},
{
"id": "3.4", "key": "selfhealing", "domain": "WORK",
"text": "Understand the primitives used to create robust, self-healing, application deployments",
"tasks": [106, 8, 28, 26, 27, 31, 52, 53, 76, 88, 70],
"know": [
"Controllers put back what is missing: a Deployment its replicas, a DaemonSet one Pod per node, a StatefulSet named replicas with their own storage, a Job its completions (backoffLimit retries), a CronJob its Jobs.",
"Probes tell the kubelet about health: liveness restarts the container, readiness takes the Pod out of Service endpoints, startup holds both off until the app is up.",
"Init containers run to completion, in order, before the app starts; a native sidecar (an init container with restartPolicy: Always) keeps running beside it.",
"A PodDisruptionBudget (minAvailable or maxUnavailable) limits how many Pods a drain may take away at once.",
],
"fast": """
containers:
- name: app
startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12}
readinessProbe: {httpGet: {path: /ready, port: 8080}, periodSeconds: 5}
livenessProbe: {tcpSocket: {port: 80}, initialDelaySeconds: 15, periodSeconds: 10}
# exec: {exec: {command: [cat, /tmp/ready]}}
k create job render --image=busybox:1.36 -- sh -c 'echo done'
k create cronjob digest --image=busybox:1.36 --schedule='*/5 * * * *' -- date; k create job run-1 --from=cronjob/digest
k create pdb web --selector=app=web --min-available=2
k create deploy x --image=busybox:1.36 $do > ds.yaml # DaemonSet: change kind, drop replicas / strategy / status
k describe pod POD | grep -E 'Liveness|Readiness|Startup'; k get events --field-selector reason=Unhealthy
""",
"traps": [
"Probes belong to a container. No kubectl flag adds one - edit the YAML.",
"Restarts with Unhealthy and Killing events but a clean log: the liveness probe is wrong, not the app.",
"A DaemonSet reaches control-plane nodes only with a toleration for node-role.kubernetes.io/control-plane.",
"A Job's Pod template cannot be edited: delete the Job and create it again.",
],
"docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/",
"https://kubernetes.io/docs/concepts/workloads/controllers/deployment/",
"https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/",
"https://kubernetes.io/docs/tasks/run-application/configure-pdb/"],
},
{
"id": "3.5", "key": "scheduling", "domain": "WORK",
"text": "Configure Pod admission and scheduling (limits, node affinity, etc.)",
"tasks": [9, 25, 24, 32, 50, 78, 96, 81, 97],
"know": [
"Requests are what the scheduler reserves, limits what the node enforces. A LimitRange gives containers defaults and bounds; a ResourceQuota caps a Namespace's totals - both act at admission.",
"Where a Pod may go: nodeSelector, node affinity (required or preferred), Pod affinity and anti-affinity with a topologyKey, topologySpreadConstraints, or nodeName, which skips the scheduler.",
"Taints keep Pods off a node (NoSchedule, PreferNoSchedule, NoExecute); a toleration lets a Pod through.",
"A PriorityClass orders Pods when room is short; the Pod names it in priorityClassName.",
],
"fast": """
k label node NODE disktype=ssd
k taint node NODE dedicated=gpu:NoSchedule; k taint node NODE dedicated- # the trailing - removes it
tolerations: [{key: dedicated, operator: Equal, value: gpu, effect: NoSchedule}]
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms: [{matchExpressions: [{key: disktype, operator: In, values: [ssd]}]}]
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- {labelSelector: {matchLabels: {app: web}}, topologyKey: kubernetes.io/hostname}
k create priorityclass high --value=100000 --description='critical workloads'
k create quota team -n NS --hard=pods=5,requests.cpu=1,limits.memory=2Gi
k describe pod POD | tail # FailedScheduling: 0/4 nodes are available: ...
""",
"traps": [
"A toleration lets a Pod onto a tainted node but does not send it there - pair it with a nodeSelector or affinity.",
"Once a quota covers CPU or memory, every new Pod must state them or get them from a LimitRange.",
"In a LimitRange, `default` is the limit and `defaultRequest` the request.",
"Scheduling fields of a running Pod cannot be changed: change the Deployment, or delete and recreate the Pod.",
],
"docs": ["https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/",
"https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/",
"https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/",
"https://kubernetes.io/docs/concepts/policy/resource-quotas/"],
},
# ------------------ 4. Cluster Architecture, Installation & Configuration (25%) ----
{
"id": "4.1", "key": "rbac", "domain": "ARCH",
"text": "Manage role based access control (RBAC)",
"tasks": [1, 33, 6, 44, 47, 75, 91],
"know": [
"A Role (one Namespace) or ClusterRole (everywhere, and cluster-scoped resources) lists verbs on resources; a RoleBinding or ClusterRoleBinding grants it to users, groups or ServiceAccounts.",
"A RoleBinding may point at a ClusterRole: the rules then apply in that one Namespace only.",
"A user is a certificate: CN is the name, O the groups. Create a key and CSR, submit a CertificateSigningRequest, approve it, put the certificate in a kubeconfig.",
"An aggregated ClusterRole has no rules of its own: the controller fills them in from every ClusterRole carrying the label it selects.",
],
"fast": """
k create role deploy-manager -n NS --verb=get,list,watch,create,update,patch,delete --resource=deployments.apps
k create rolebinding deploy-bot -n NS --role=deploy-manager --serviceaccount=NS:deploy-bot # --user=jane --group=devs
k create clusterrole node-reader --verb=get,list,watch --resource=nodes
k create clusterrolebinding node-reader --clusterrole=node-reader --serviceaccount=NS:SA
k auth can-i create deployments -n NS --as=system:serviceaccount:NS:deploy-bot; k auth can-i --list --as=jane -n NS
openssl genrsa -out jane.key 2048; openssl req -new -key jane.key -subj '/CN=jane/O=developers' -out jane.csr
# CertificateSigningRequest: spec.request = base64 -w0 of the .csr, signerName kubernetes.io/kube-apiserver-client, usages [client auth]
k certificate approve jane; k get csr jane -o jsonpath='{.status.certificate}' | base64 -d > jane.crt
k config set-credentials jane --client-certificate=jane.crt --client-key=jane.key --embed-certs
""",
"traps": [
"\"Exactly these permissions\" also means finding what is already granted: `k get rolebindings,clusterrolebindings -A -o wide | grep NAME`.",
"Deployments are in the group apps: --resource=deployments.apps; a subresource is deployments.apps/scale, pods/log.",
"The same ServiceAccount is `--serviceaccount=NS:NAME` in a binding and `system:serviceaccount:NS:NAME` after `--as`.",
"Nodes, PersistentVolumes and Namespaces are cluster-scoped: they need a ClusterRole and a ClusterRoleBinding.",
],
"docs": ["https://kubernetes.io/docs/reference/access-authn-authz/rbac/",
"https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/"],
},
{
"id": "4.2", "key": "infra", "domain": "ARCH",
"text": "Prepare underlying infrastructure for installing a Kubernetes cluster",
"tasks": [107, 108, 41],
"know": [
"Every machine needs, before kubeadm: a container runtime (containerd) using the same cgroup driver as the kubelet (systemd), the kernel modules overlay and br_netfilter, the sysctls net.bridge.bridge-nf-call-iptables and net.ipv4.ip_forward, and swap off or accounted for.",
"kubeadm, kubelet and kubectl come from the package repository of one minor version (pkgs.k8s.io) and are held, so that a system update does not move them.",
"Also: a unique hostname, MAC address and product_uuid per node, and the ports open - 6443 (API), 10250 (kubelet), 2379-2380 (etcd).",
],
"fast": """
printf 'overlay\\nbr_netfilter\\n' > /etc/modules-load.d/k8s.conf; modprobe overlay; modprobe br_netfilter
cat > /etc/sysctl.d/k8s.conf <<EOF
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sysctl --system
swapoff -a; sed -i '/ swap / s/^/#/' /etc/fstab; cat /proc/swaps
systemctl enable --now containerd; grep SystemdCgroup /etc/containerd/config.toml
apt-get install -y kubelet kubeadm kubectl; apt-mark hold kubelet kubeadm kubectl; dpkg -i ./some-package.deb
crictl version; cat /etc/crictl.yaml; grep -E 'cgroupDriver|containerRuntimeEndpoint' /var/lib/kubelet/config.yaml
""",
"traps": [
"`sysctl -w` and `modprobe` are gone after a reboot - it is the files in /etc/sysctl.d and /etc/modules-load.d that last.",
"A kubelet on cgroupfs with a runtime on systemd (or the other way round) gives an unstable node.",
"kubeadm's preflight stops at active swap unless the kubelet is configured for it (failSwapOn: false).",
],
"docs": ["https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/",
"https://kubernetes.io/docs/setup/production-environment/container-runtimes/"],
},
{
"id": "4.3", "key": "kubeadm", "domain": "ARCH",
"text": "Create and manage Kubernetes clusters using kubeadm",
"tasks": [40, 5, 6, 67, 46, 94, 95, 80],
"know": [
"`kubeadm init` makes the control plane: certificates in /etc/kubernetes/pki, kubeconfigs in /etc/kubernetes, static Pod manifests in /etc/kubernetes/manifests, then CoreDNS and kube-proxy. A CNI plugin is yours to add.",
"`kubeadm join` adds a node with a bootstrap token and the hash of the cluster CA; `kubeadm token create --print-join-command` writes the whole line.",
"What the cluster was built with is in ConfigMap kubeadm-config (kube-system). `kubeadm certs check-expiration` lists every certificate it manages.",
"A static Pod is a manifest in the kubelet's staticPodPath; it shows up in the API as NAME-NODE and can only be changed or removed through the file.",
],
"fast": """
kubeadm init --pod-network-cidr=10.244.0.0/16 --control-plane-endpoint=HOST:6443
kubeadm token create --ttl 2h --print-join-command; kubeadm token list
openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex # the CA hash
kubeadm certs check-expiration; openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -enddate
k -n kube-system get cm kubeadm-config -o yaml
grep staticPodPath /var/lib/kubelet/config.yaml; k run web --image=nginx:1.27 $do > /etc/kubernetes/manifests/web.yaml
""",
"traps": [
"Tokens expire (24 hours unless you say otherwise) - make a new one rather than hunting for the old.",
"Check staticPodPath before you write a static Pod: it is not always /etc/kubernetes/manifests.",
"Deleting a static Pod with kubectl only deletes its mirror; the kubelet puts it straight back.",
],
"docs": ["https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/",
"https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-token/",
"https://kubernetes.io/docs/reference/setup-tools/kubeadm/kubeadm-join/",
"https://kubernetes.io/docs/tasks/configure-pod-container/static-pod/"],
},
{
"id": "4.4", "key": "lifecycle", "domain": "ARCH",
"text": "Manage the lifecycle of Kubernetes clusters",
"tasks": [109, 2, 65, 45, 80],
"know": [
"Upgrade one minor version at a time, control plane first: the kubeadm package, `kubeadm upgrade plan` and `apply`, drain, the kubelet and kubectl packages, restart the kubelet, uncordon. Workers: `kubeadm upgrade node` in place of `apply`.",
"Maintenance on a node: `kubectl drain` cordons it and evicts its Pods; `kubectl uncordon` gives it back.",
"etcd: `etcdctl snapshot save` backs it up; `etcdutl snapshot restore --data-dir` unpacks a snapshot into a new directory that etcd's manifest must then point at.",
"`kubeadm certs renew all` renews the control-plane certificates; the static Pods have to be restarted to load them.",
],
"fast": """
k drain NODE --ignore-daemonsets --delete-emptydir-data; k uncordon NODE
apt-get install -y kubeadm='1.36.*'; kubeadm upgrade plan; kubeadm upgrade apply v1.36.x # workers: kubeadm upgrade node
apt-get install -y kubelet='1.36.*' kubectl='1.36.*'; systemctl daemon-reload; systemctl restart kubelet
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 snapshot save /opt/backup.db
etcdutl snapshot restore /opt/backup.db --data-dir /var/lib/etcd-restore # then etcd.yaml: the hostPath of volume etcd-data
kubeadm certs renew all; kubeadm certs check-expiration
""",
"traps": [
"drain stops at DaemonSet Pods (--ignore-daemonsets), at emptyDir data (--delete-emptydir-data) and at bare Pods (--force, which deletes them for good).",
"A drained node stays cordoned until you uncordon it.",
"With etcd 3.6, as here, restore exists only in etcdutl - `etcdctl snapshot restore` is gone.",
"Restoring etcd takes the whole cluster back to the snapshot: everything created since is lost.",
],
"docs": ["https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/",
"https://kubernetes.io/docs/tasks/administer-cluster/safely-drain-node/",
"https://kubernetes.io/docs/tasks/administer-cluster/configure-upgrade-etcd/",
"https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-certs/"],
},
{
"id": "4.5", "key": "ha", "domain": "ARCH",
"text": "Implement and configure a highly-available control plane",
"tasks": [110, 111, 86, 87],
"know": [
"Highly available means several control-plane nodes (three as a rule) behind one stable address - the controlPlaneEndpoint, a load balancer or virtual IP, fixed at `kubeadm init`.",
"etcd is either stacked (a member on every control-plane node) or external (hosts of its own). It needs a majority to accept writes: 3 members survive the loss of 1, 5 of 2.",
"More control-plane nodes join with `kubeadm join --control-plane --certificate-key KEY`; the key comes from `kubeadm init phase upload-certs --upload-certs` and is good for two hours.",
"Every kube-apiserver serves at once. Of the schedulers and controller-managers only the leader acts - they elect it through a Lease in kube-system.",
],
"fast": """
k get nodes -l node-role.kubernetes.io/control-plane
k -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint
kubeadm init --control-plane-endpoint=LB:6443 --upload-certs
kubeadm init phase upload-certs --upload-certs # prints the certificate key
kubeadm token create --print-join-command --certificate-key KEY # ... --control-plane --certificate-key KEY
k -n kube-system get lease kube-scheduler kube-controller-manager # HOLDER = NODE_uuid
etcdctl member list -w table; etcdctl endpoint status -w table; etcdctl endpoint health # with the four etcd flags
k -n kube-system get pods -l component=etcd -o wide
""",
"traps": [
"A cluster initialised without a controlPlaneEndpoint cannot simply be given a second control-plane node.",
"An even number of etcd members tolerates no more failures than the odd number below it.",
"The certificate key expires after two hours - upload the certificates again rather than reusing an old key.",
],
"docs": ["https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/high-availability/",
"https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/ha-topology/"],
},
{
"id": "4.6", "key": "helmkustomize", "domain": "ARCH",
"text": "Use Helm and Kustomize to install cluster components",
"tasks": [21, 3, 48, 69, 98],
"know": [
"Helm: a chart is the package, a release one installation of it in a Namespace, and each install, upgrade or rollback a revision. Values come from the chart, then -f files, then --set.",
"`helm template` renders a chart without installing it; `helm show values` prints its defaults; `helm get values` what a release was given.",
"Kustomize: a kustomization.yaml lists resources and changes them - namespace, namePrefix, labels, images, replicas, patches - and generates ConfigMaps and Secrets. An overlay lists a base and changes only what differs.",
],
"fast": """
helm repo add NAME URL && helm repo update; helm search repo NAME --versions
helm install REL CHART -n NS --create-namespace --set replicaCount=2 -f values.yaml # --version X
helm upgrade REL CHART -n NS --reuse-values --set image.tag=1.27
helm template REL CHART -n NS --set replicaCount=3 > manifest.yaml
helm list -A; helm history REL -n NS; helm rollback REL 1 -n NS; helm uninstall REL -n NS
k kustomize DIR | less; k apply -k DIR
# kustomization.yaml: resources: [../../base] namespace: prod namePrefix: prod- images: [{name: nginx, newTag: "1.27"}]
# replicas: [{name: web, count: 3}] patches: [{path: patch.yaml}] configMapGenerator: [{name: cfg, literals: [k=v]}]
""",
"traps": [
"Releases live in a Namespace: without -n Helm looks in `default`.",
"An upgrade without --reuse-values (or the same -f and --set again) puts every value back to the chart's default.",
"In a kustomization, `images`, `replicas` and patches use the names of the base, before any prefix.",
"`namespace:` in a kustomization does not create the Namespace.",
],
"docs": ["https://helm.sh/docs/intro/using_helm/", "https://helm.sh/docs/helm/helm_template/",
"https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/"],
},
{
"id": "4.7", "key": "interfaces", "domain": "ARCH",
"text": "Understand extension interfaces (CNI, CSI, CRI, etc.)",
"tasks": [112, 108, 41, 63, 77, 87],
"know": [
"CRI: the kubelet drives the container runtime (containerd, CRI-O) through a socket; `crictl` speaks the same protocol, so it works on a node when kubectl does not.",
"CNI: for every Pod the runtime calls the plugins named in /etc/cni/net.d (binaries in /opt/cni/bin) to give it an address and wire it up. The plugin - kindnet, Calico, Cilium, Flannel - usually runs as a DaemonSet.",
"CSI: a storage driver runs as Pods, registers a CSIDriver object, and a StorageClass names it as provisioner.",
],
"fast": """
k get nodes -o wide # CONTAINER-RUNTIME column
crictl version; crictl pods; crictl ps -a; crictl images; crictl inspect CONTAINER-ID
grep containerRuntimeEndpoint /var/lib/kubelet/config.yaml; cat /etc/crictl.yaml
ls /etc/cni/net.d; cat /etc/cni/net.d/*; ls /opt/cni/bin
k -n kube-system get ds # the CNI's DaemonSet beside kube-proxy
k get csidrivers; k get csinodes; k get sc # PROVISIONER
""",
"traps": [
"Not every CNI plugin enforces NetworkPolicy (Flannel does not; kindnet in this cluster does).",
"Without a CNI plugin the nodes stay NotReady: `cni plugin not initialized`.",
"The runtime takes the first configuration file in /etc/cni/net.d, in alphabetical order.",
],
"docs": ["https://kubernetes.io/docs/concepts/extend-kubernetes/compute-storage-net/network-plugins/",
"https://kubernetes.io/docs/concepts/containers/cri/",
"https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/"],
},
{
"id": "4.8", "key": "crds", "domain": "ARCH",
"text": "Understand CRDs, install and configure operators",
"tasks": [4, 34, 49, 91],
"know": [
"A CustomResourceDefinition adds a kind to the API: group, names (kind, plural, singular, shortNames), scope (Namespaced or Cluster) and versions, each with an OpenAPI v3 schema.",
"The schema validates custom resources - types, required fields, enum, minimum, maximum, defaults - and feeds `kubectl explain`.",
"An operator is a CRD plus a controller (a Deployment with RBAC) that acts on those objects. Installing one means applying its manifests or its Helm chart, then creating a custom resource.",
],
"fast": """
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: backups.data.example.com} # PLURAL.GROUP
spec:
group: data.example.com
scope: Namespaced
names: {kind: Backup, plural: backups, singular: backup, shortNames: [bk]}
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [schedule]
properties:
schedule: {type: string}
retention: {type: integer, minimum: 1, default: 7}
k get crd; k api-resources --api-group=data.example.com; k explain backup.spec --recursive
""",
"traps": [
"The CRD's name must be PLURAL.GROUP, and exactly one version has `storage: true`.",
"A custom resource's apiVersion is GROUP/VERSION of the CRD, not apiextensions.k8s.io/v1.",
"`default` and validation only work inside a structural schema: every level needs its `type`.",
"Deleting a CRD deletes every object of that kind.",
],
"docs": ["https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/",
"https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/",
"https://kubernetes.io/docs/concepts/extend-kubernetes/operator/"],
},
# -------------------------------------------------- 5. Services & Networking (20%) ----
{
"id": "5.1", "key": "connectivity", "domain": "NET",
"text": "Understand connectivity between Pods",
"tasks": [113, 63, 71, 77, 87],
"know": [
"Every Pod has its own IP address, and any Pod reaches any other Pod on any node at that address without NAT. The CNI plugin makes that true.",
"Each node hands out Pod addresses from its own range (spec.podCIDR), a slice of the cluster's Pod CIDR; Services get theirs from the separate service CIDR.",
"A hostNetwork Pod lives in the node's network: it has the node's address and shares its ports.",
"Containers of one Pod share a network namespace and talk over localhost.",
],
"fast": """
k get pods -o wide -A; k get nodes -o custom-columns=NODE:.metadata.name,PODCIDR:.spec.podCIDR,IP:.status.addresses[0].address
k exec POD -- wget -qO- -T3 http://POD-IP:PORT
k -n kube-system get cm kubeadm-config -o yaml | grep -E 'podSubnet|serviceSubnet'
k -n kube-system get pods -o wide | grep -E 'kube-proxy|kindnet'; k -n kube-system get cm kube-proxy -o yaml | grep mode
""",
"traps": [
"A Pod's address changes whenever the Pod is replaced - that is what Services are for.",
"Reaching a Pod by IP proves the network; reaching it by Service name also needs endpoints and DNS.",
"If only the Pods of one node are unreachable, look at that node's CNI and kube-proxy Pods.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/",
"https://kubernetes.io/docs/concepts/cluster-administration/networking/"],
},
{
"id": "5.2", "key": "netpol", "domain": "NET",
"text": "Define and enforce Network Policies",
"tasks": [11, 42, 54, 83, 100, 77],
"know": [
"Pods accept and send everything until a NetworkPolicy selects them. From then on, for the policyTypes it lists, only what some policy allows gets through.",
"Policies only allow, and they add up: there is no deny rule and no ordering.",
"Peers: podSelector (same Namespace), namespaceSelector, ipBlock. Two selectors in one list item mean AND; two list items mean OR.",
"Default deny: `podSelector: {}` with the policyTypes and no rules. An egress lock-down also has to allow DNS (UDP and TCP 53).",
],
"fast": """
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: backend-policy, namespace: NS}
spec:
podSelector: {matchLabels: {app: backend}}
policyTypes: [Ingress, Egress]
ingress:
- from: [{podSelector: {matchLabels: {app: frontend}}}]
ports: [{protocol: TCP, port: 8080}]
egress:
- to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}, podSelector: {matchLabels: {k8s-app: kube-dns}}}]
ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
k describe netpol backend-policy -n NS; k exec CLIENT -- wget -qO- -T2 http://backend:8080
""",
"traps": [
"There is no `kubectl create networkpolicy` - copy the skeleton from the docs.",
"`namespaceSelector` and `podSelector` in the same item: Pods with that label in those Namespaces. In two items: either.",
"A missing `ingress` denies everything; `ingress: [{}]` allows everything.",
"A Namespace is matched by its labels; kubernetes.io/metadata.name=NAME is always there.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/network-policies/"],
},
{
"id": "5.3", "key": "services", "domain": "NET",
"text": "Use ClusterIP, NodePort, LoadBalancer service types and endpoints",
"tasks": [12, 30, 43, 64, 71, 73, 94],
"know": [
"A Service selects Pods by label and forwards `port` to `targetPort` - a number, or the name of a container port.",
"ClusterIP is reachable inside the cluster; NodePort adds a port (30000-32767) on every node; LoadBalancer adds an external address from the cloud; ExternalName is a DNS alias; headless (clusterIP: None) gives every Pod its own DNS record.",
"The Pods behind a Service are its endpoints, kept in EndpointSlices; only Ready Pods are in them.",
],
"fast": """
k expose deploy shop --name=shop-svc --port=80 --target-port=80 --type=NodePort
k create svc nodeport shop-svc --tcp=80:80 --node-port=30080 # then check the selector
k create svc externalname legacy --external-name=db.example.com
k get svc,endpointslices -o wide; k describe svc shop-svc # Endpoints:
k get pods -l app=shop --show-labels
k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://shop-svc
""",
"traps": [
"`kubectl expose` copies the workload's selector; `kubectl create svc` selects app=<service name>, which may match nothing.",
"`expose` cannot choose the nodePort - use `create svc nodeport --node-port` or edit the YAML.",
"A LoadBalancer stays <pending> where nothing provides one; it still works as a NodePort.",
"A headless Service for a StatefulSet has to carry the name in the StatefulSet's serviceName.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/service/",
"https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/"],
},
{
"id": "5.4", "key": "gateway", "domain": "NET",
"text": "Use the Gateway API to manage Ingress traffic",
"tasks": [22, 56, 74],
"know": [
"Three kinds, three owners: a GatewayClass names the controller, a Gateway opens listeners (port, protocol, hostname, which routes may attach), an HTTPRoute attaches to a Gateway (parentRefs) and sends requests to Services (backendRefs).",
"An HTTPRoute rule matches on path, header, method or query, and may split traffic between backends by weight - that is a canary.",
"The kinds are CRDs (gateway.networking.k8s.io): they have to be installed, and a controller has to implement them.",
],
"fast": """
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: web-gw, namespace: NS}
spec:
gatewayClassName: example
listeners: [{name: http, protocol: HTTP, port: 80, hostname: shop.example.com}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: web, namespace: NS}
spec:
parentRefs: [{name: web-gw}]
hostnames: [shop.example.com]
rules:
- matches: [{path: {type: PathPrefix, value: /}}]
backendRefs: [{name: v1, port: 80, weight: 90}, {name: v2, port: 80, weight: 10}]
k get gatewayclass,gateway,httproute -A; k describe httproute web -n NS
""",
"traps": [
"A route in another Namespace attaches only if the listener's allowedRoutes lets it.",
"A header match belongs in `matches`, beside or instead of the path - and several rules are tried most specific first.",
"No Gateway controller runs in this simulator, so these tasks are graded on the objects.",
],
"docs": ["https://gateway-api.sigs.k8s.io/guides/user-guides/http-routing/",
"https://kubernetes.io/docs/concepts/services-networking/gateway/"],
},
{
"id": "5.5", "key": "ingress", "domain": "NET",
"text": "Know how to use Ingress controllers and Ingress resources",
"tasks": [13, 55],
"know": [
"An Ingress routes HTTP(S) by host and path to Services of its own Namespace. It does nothing without an ingress controller, chosen with ingressClassName (an IngressClass names the controller).",
"Each path has a pathType (Prefix, Exact or ImplementationSpecific) and a backend: service.name plus service.port.number or .name.",
"TLS: spec.tls lists hosts and the kubernetes.io/tls Secret that holds their certificate.",
],
"fast": """
k create ingress app-ingress -n NS --class=nginx \\
--rule='shop.example.com/api*=api:8080,tls=shop-tls' --rule='shop.example.com/*=web:80,tls=shop-tls'
openssl req -x509 -nodes -newkey rsa:2048 -days 365 -subj '/CN=shop.example.com' -keyout tls.key -out tls.crt
k create secret tls shop-tls -n NS --cert=tls.crt --key=tls.key
k get ingress,ingressclass -A; k describe ingress app-ingress -n NS
""",
"traps": [
"In --rule a trailing * means pathType Prefix; without it the path is Exact.",
"The backend port is the Service's port, not the container's.",
"No ingress controller runs in this simulator, so Ingress tasks are graded on the object - on the exam, curl it.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/ingress/",
"https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/"],
},
{
"id": "5.6", "key": "coredns", "domain": "NET",
"text": "Understand and use CoreDNS",
"tasks": [14, 37, 57, 101],
"know": [
"CoreDNS is a Deployment in kube-system behind the Service kube-dns; its configuration, the Corefile, is in ConfigMap coredns: the `kubernetes` plugin answers for cluster.local, `forward` sends the rest upstream.",
"Names: SVC.NS.svc.cluster.local for a Service, POD.SVC.NS.svc.cluster.local for the Pods of a headless Service.",
"A Pod's /etc/resolv.conf points at the kube-dns address and lists the search domains that make short names work. dnsPolicy decides: ClusterFirst (default), Default (the node's resolver), None with dnsConfig.",
],
"fast": """
k -n kube-system get deploy coredns; k -n kube-system get pods -l k8s-app=kube-dns -o wide
k -n kube-system get svc kube-dns; k -n kube-system get endpointslices -l k8s-app=kube-dns
k -n kube-system get cm coredns -o yaml; k -n kube-system edit cm coredns
k -n kube-system logs -l k8s-app=kube-dns; k -n kube-system rollout restart deploy coredns
k run tmp --rm -it --restart=Never --image=busybox:1.36 -- nslookup kubernetes.default.svc.cluster.local
k exec POD -- cat /etc/resolv.conf
""",
"traps": [
"A typo in the Corefile puts CoreDNS in CrashLoopBackOff - its log names the plugin and the line.",
"DNS can be broken in several places at once: the Corefile, the Deployment (replicas, image), the kube-dns Service (selector, ports), a NetworkPolicy.",
"A Pod with dnsPolicy: Default resolves the internet but not cluster names.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/",
"https://kubernetes.io/docs/tasks/administer-cluster/dns-custom-nameservers/",
"https://kubernetes.io/docs/tasks/administer-cluster/dns-debugging-resolution/"],
},
]

View File

@ -639,12 +639,10 @@ $K -n default delete pod sched-probe --wait=false >/dev/null 2>&1
] ]
# ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 44-67 (hard) in bank_hard, # ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 44-67 (hard) in bank_hard,
# ---- 68-85 / 86-102 in the two fixed simulator sets, 103-113 (pool tasks for the objectives the # ---- 68-85 / 86-102 in the two fixed simulator sets. Numbering is contiguous - keep it that way.
# ---- curriculum map found thin, see curriculum.py) in bank_objectives. Numbering is contiguous - from tracks.cka import bank_extra, bank_hard, bank_sim_c, bank_sim_d
# ---- keep it that way.
from tracks.cka import bank_extra, bank_hard, bank_objectives, bank_sim_c, bank_sim_d
QUESTIONS = (QUESTIONS + bank_extra.QUESTIONS + bank_hard.QUESTIONS QUESTIONS = (QUESTIONS + bank_extra.QUESTIONS + bank_hard.QUESTIONS
+ bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS + bank_objectives.QUESTIONS) + bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS)
QUESTIONS.sort(key=lambda q: q["id"]) QUESTIONS.sort(key=lambda q: q["id"])
_ids = [q["id"] for q in QUESTIONS] _ids = [q["id"] for q in QUESTIONS]
assert len(_ids) == len(set(_ids)), "duplicate question ids" assert len(_ids) == len(set(_ids)), "duplicate question ids"

View File

@ -1,741 +0,0 @@
"""CKAD tasks 58-64: a second pool task for every curriculum objective that had only one.
curriculum.py turns the bank into a study path, one objective at a time, and an objective with a
single task is studied once and never drilled on anything new. Each task here takes its objective
from another side than the first one does: API deprecations (a warning, not a removal), CRDs (a
custom resource the API server rejects), images (ENTRYPOINT, CMD and USER), Kustomize (one that
does not build), probes (checks that are wrong, not missing), kubectl output (custom columns and
selectors), logs (a selector, a prefix and an init container).
Same format as questions.py; every grader reads the live cluster or the files the task asks for.
"""
from tracks.cka.common import C, NS, ns_pick, name_pick
QUESTIONS = [
# ------------------------------------------------------------------- OBS ----
{
"id": 58, "domain": "OBS", "title": "Removed, and deprecated but still served",
"params": lambda r: {"ns": ns_pick(r, "migrate"), "app": name_pick(r, "catalog"), "pct": r.choice([10, 25, 50])},
"task": """
`{WS}/q58` holds two manifests written for an older cluster.
1. `deployment.yaml` no longer applies. Correct the file - same name, Namespace, replicas, labels
and container - so that it applies to this cluster, and apply it.
2. `flag.yaml` still applies, but the API server answers with a deprecation warning. Write the
warning to `{WS}/q58/warning.txt`, change the file to the version the warning points to, and
apply it.
3. Write the API version this cluster serves for each of the kinds `CronJob`,
`HorizontalPodAutoscaler`, `EndpointSlice` and `PriorityClass` to `{WS}/q58/served.txt`, one
per line as `<Kind> <group/version>`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q58"; mkdir -p "$WS/q58"
$K -n %{ns} delete deploy %{app} --ignore-not-found >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: featureflags.rollout.example.com}
spec:
group: rollout.example.com
scope: Namespaced
names: {plural: featureflags, singular: featureflag, kind: FeatureFlag, shortNames: [ff]}
versions:
- name: v1beta1
served: true
storage: false
deprecated: true
deprecationWarning: "rollout.example.com/v1beta1 FeatureFlag is deprecated and will stop being served; use rollout.example.com/v1 FeatureFlag"
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [enabled]
properties:
enabled: {type: boolean}
percent: {type: integer, minimum: 0, maximum: 100}
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [enabled]
properties:
enabled: {type: boolean}
percent: {type: integer, minimum: 0, maximum: 100}
EOF
$K wait --for=condition=Established crd/featureflags.rollout.example.com --timeout=60s >/dev/null
$K -n %{ns} delete featureflags.rollout.example.com --all --wait=true >/dev/null 2>&1
cat > "$WS/q58/deployment.yaml" <<'EOF'
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: %{app}
namespace: %{ns}
labels: {app: %{app}}
spec:
replicas: 2
template:
metadata:
labels: {app: %{app}, tier: web}
spec:
containers:
- name: web
image: nginx:1.27
ports: [{containerPort: 80}]
EOF
cat > "$WS/q58/flag.yaml" <<'EOF'
apiVersion: rollout.example.com/v1beta1
kind: FeatureFlag
metadata:
name: %{app}-checkout
namespace: %{ns}
spec:
enabled: true
percent: %{pct}
EOF
""",
"checks": [
C("deployment.yaml is apps/v1 and applies; Deployment %{app} has 2 available with its labels", 2, r"""
f="$WS/q58/deployment.yaml"
grep -Eq '^apiVersion:[[:space:]]*apps/v1[[:space:]]*$' "$f" 2>/dev/null || exit 1
$K apply --dry-run=server -f "$f" >/dev/null 2>&1 || exit 1
d="$K -n %{ns} get deploy %{app}"
eq "$($d -o jsonpath='{.spec.replicas}/{.spec.template.metadata.labels.app}/{.spec.template.metadata.labels.tier}/{.spec.template.spec.containers[0].name}/{.spec.template.spec.containers[0].image}' 2>/dev/null)" \
"2/%{app}/web/web/nginx:1.27" || exit 1
for i in $(seq 1 30); do [ "$($d -o jsonpath='{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""),
C("warning.txt holds the deprecation warning", 1, r"""
grep -q 'rollout.example.com/v1beta1 FeatureFlag is deprecated' "$WS/q58/warning.txt" 2>/dev/null"""),
C("flag.yaml uses rollout.example.com/v1, applies without a warning, and FeatureFlag %{app}-checkout exists", 2, r"""
f="$WS/q58/flag.yaml"
grep -Eq '^apiVersion:[[:space:]]*rollout\.example\.com/v1[[:space:]]*$' "$f" 2>/dev/null || exit 1
out=$($K apply --dry-run=server -f "$f" 2>&1) || exit 1
echo "$out" | grep -qi deprecated && exit 1
eq "$(j -n %{ns} featureflags.rollout.example.com %{app}-checkout '{.spec.enabled}/{.spec.percent}')" "true/%{pct}" """),
C("served.txt: the four kinds with the versions this cluster serves", 2, r"""
want=$($K api-resources --no-headers 2>/dev/null | awk '{k=$NF; if (k=="CronJob" || k=="HorizontalPodAutoscaler" || k=="EndpointSlice" || k=="PriorityClass") print k, $(NF-2)}' | sort)
[ "$(echo "$want" | grep -c .)" = 4 ] || exit 1
got=$(tr -s ' \t' ' ' < "$WS/q58/served.txt" 2>/dev/null | sed 's/^ *//; s/ *$//' | tr -d '\r' | grep . | sort)
eq "$got" "$want" """),
],
"solution": """
k apply -f {WS}/q58/deployment.yaml --dry-run=server # no matches for kind "Deployment" in version "extensions/v1beta1"
k api-resources | grep -w Deployment # apps/v1
vi {WS}/q58/deployment.yaml
# apiVersion: apps/v1
# spec:
# selector: {matchLabels: {app: %{app}}} <- apps/v1 requires it; the old API made it up from the template labels
k apply -f {WS}/q58/deployment.yaml
k apply -f {WS}/q58/flag.yaml --dry-run=server 2> {WS}/q58/warning.txt
cat {WS}/q58/warning.txt # Warning: rollout.example.com/v1beta1 FeatureFlag is deprecated ...; use rollout.example.com/v1 FeatureFlag
sed -i 's#rollout.example.com/v1beta1#rollout.example.com/v1#' {WS}/q58/flag.yaml
k apply -f {WS}/q58/flag.yaml # no warning any more
k api-resources | grep -w -E 'CronJob|HorizontalPodAutoscaler|EndpointSlice|PriorityClass' # APIVERSION column
cat > {WS}/q58/served.txt <<EOF
CronJob batch/v1
HorizontalPodAutoscaler autoscaling/v2
EndpointSlice discovery.k8s.io/v1
PriorityClass scheduling.k8s.io/v1
EOF
# Removed = "no matches for kind": nothing happens until the manifest is rewritten. Deprecated =
# a Warning on stderr while the object is still created - the time to migrate, before the version
# is removed. The warning is real API machinery (here a CRD version marked deprecated); built-in
# APIs print the same line, e.g. "batch/v1beta1 CronJob is deprecated in v1.21+, unavailable in v1.25+".
""",
},
{
"id": 62, "domain": "OBS", "title": "Health checks that check the wrong thing",
"params": lambda r: (lambda pair: {"ns": ns_pick(r, "health"), "dep": name_pick(r, "gateway"),
"port": r.choice([8080, 8081, 9090]), "live": pair[0], "ready": pair[1]})(
r.choice([("healthz", "ready"), ("livez", "readyz"), ("alive", "warm")])),
"task": """
Pods of Deployment `%{dep}` in Namespace `%{ns}` are restarted again and again and never become
Ready. The application is healthy - its health checks are wrong. ConfigMap `%{dep}-conf` shows how
the app listens.
1. Write the reason the kubelet puts on its events when a probe fails to `{WS}/q62/reason.txt`.
2. Correct the probes: liveness must request the app's liveness endpoint and readiness its
readiness endpoint, both on the port the app listens on. Keep them HTTP probes with their
timings.
3. Both replicas must be Ready and stay up without restarts. Do not change the ConfigMap.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q62"; mkdir -p "$WS/q62"
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata: {name: %{dep}-conf, namespace: %{ns}}
data:
default.conf: |
server {
listen %{port};
# liveness endpoint
location = /%{live} { return 200 "alive\n"; }
# readiness endpoint
location = /%{ready} { return 200 "ready\n"; }
location / { return 404; }
}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}, labels: {app: %{dep}}}
spec:
replicas: 2
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: gateway
image: nginx:1.27
ports: [{containerPort: %{port}}]
volumeMounts: [{name: conf, mountPath: /etc/nginx/conf.d}]
livenessProbe:
httpGet: {path: /%{live}, port: 80}
periodSeconds: 5
failureThreshold: 2
readinessProbe:
httpGet: {path: /status, port: %{port}}
periodSeconds: 5
volumes: [{name: conf, configMap: {name: %{dep}-conf}}]
EOF
$K -n %{ns} get cm %{dep}-conf -o jsonpath='{.data.default\.conf}' | md5sum | cut -d' ' -f1 > "$WS/.q62-conf.md5"
""",
"checks": [
C("reason.txt holds Unhealthy", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q62/reason.txt" 2>/dev/null)" Unhealthy"""),
C("liveness: GET /%{live} on port %{port}, every 5 s, 2 failures", 2, r"""
$K -n %{ns} get deploy %{dep} -o json 2>/dev/null | python3 -c '
import sys, json
c = json.load(sys.stdin)["spec"]["template"]["spec"]["containers"][0]
p = c.get("livenessProbe") or {}; h = p.get("httpGet") or {}
named = {x.get("name"): x.get("containerPort") for x in c.get("ports", [])}
port = named.get(h.get("port"), h.get("port"))
sys.exit(0 if (h.get("path"), port, p.get("periodSeconds"), p.get("failureThreshold")) == ("/%{live}", %{port}, 5, 2) else 1)'"""),
C("readiness: GET /%{ready} on port %{port}, every 5 s", 2, r"""
$K -n %{ns} get deploy %{dep} -o json 2>/dev/null | python3 -c '
import sys, json
c = json.load(sys.stdin)["spec"]["template"]["spec"]["containers"][0]
p = c.get("readinessProbe") or {}; h = p.get("httpGet") or {}
named = {x.get("name"): x.get("containerPort") for x in c.get("ports", [])}
port = named.get(h.get("port"), h.get("port"))
sys.exit(0 if (h.get("path"), port, p.get("periodSeconds")) == ("/%{ready}", %{port}, 5) else 1)'"""),
C("2/2 Ready and still not restarted 12 s later; ConfigMap untouched", 2, r"""
d="$K -n %{ns} get deploy %{dep}"; ok=
for i in $(seq 1 20); do [ "$($d -o jsonpath='{.status.readyReplicas}/{.status.updatedReplicas}' 2>/dev/null)" = 2/2 ] && { ok=1; break; }; sleep 3; done
[ -n "$ok" ] || exit 1
sleep 12
$K -n %{ns} get pods -l app=%{dep} -o json | python3 -c '
import sys, json
pods = [p for p in json.load(sys.stdin)["items"] if not p["metadata"].get("deletionTimestamp")]
cs = [s for p in pods for s in p.get("status", {}).get("containerStatuses", [])]
sys.exit(0 if len(pods) == 2 and len(cs) == 2 and all(s["ready"] and s["restartCount"] == 0 for s in cs) else 1)' || exit 1
eq "$($K -n %{ns} get cm %{dep}-conf -o jsonpath='{.data.default\.conf}' | md5sum | cut -d' ' -f1)" "$(cat "$WS/.q62-conf.md5" 2>/dev/null)" """),
],
"solution": """
k -n %{ns} get pods # RESTARTS climbing, 0/1 READY
k -n %{ns} describe pod -l app=%{dep} --show-events | grep -E 'Liveness|Readiness|Unhealthy|Killing'
# Liveness: http-get http://:80/%{live} ... Readiness: http-get http://:%{port}/status ...
# Warning Unhealthy Liveness probe failed: Get "http://10.244.x.y:80/%{live}": ... connect: connection refused
# Warning Unhealthy Readiness probe failed: HTTP probe failed with statuscode: 404
# Normal Killing Container gateway failed liveness probe, will be restarted
# (kubectl 1.36 and later leave the Events out when a selector matches several Pods - hence --show-events)
echo Unhealthy > {WS}/q62/reason.txt
k -n %{ns} get cm %{dep}-conf -o yaml # listen %{port}; /%{live} = liveness, /%{ready} = readiness
k -n %{ns} edit deploy %{dep}
# livenessProbe.httpGet: {path: /%{live}, port: %{port}} (was port 80: connection refused -> restart)
# readinessProbe.httpGet: {path: /%{ready}, port: %{port}} (was /status: 404 -> never Ready)
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} get pods # 1/1 Running, RESTARTS 0
# "connection refused" is a port problem, a status code is a path problem. A failing liveness
# probe restarts the container; a failing readiness probe only keeps the Pod out of the Service.
""",
},
{
"id": 63, "domain": "OBS", "title": "An inventory with kubectl alone",
"params": lambda r: {"ns": ns_pick(r, "stock"), "busy": r.choice(["W1", "W2", "W3"]), "tier": r.choice(["web", "api"])},
"task": """
Namespace `%{ns}` runs a handful of Pods; some of them never started.
1. Write a command into `{WS}/q63/inventory.sh` that prints one line per Pod of `%{ns}` in exactly
three columns, under the headers `POD`, `NODE` and `IMAGE`: the Pod's name, the node it runs on
and the image of its first container.
2. Write the names of the Pods labelled `tier=%{tier}` that are **not** in phase `Running` to
`{WS}/q63/not-running.txt`, one per line.
3. Write the name of the node that runs the most Pods of `%{ns}` to `{WS}/q63/busiest.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q63"; mkdir -p "$WS/q63"
$K -n %{ns} delete pod --all --wait=true --grace-period=1 >/dev/null 2>&1
busy=$%{busy}; others=$(printf '%s\n' "$W1" "$W2" "$W3" | grep -vx "$busy")
o1=$(echo "$others" | sed -n 1p); o2=$(echo "$others" | sed -n 2p)
pod() { # name tier image nodeSelector-key nodeSelector-value
cat <<EOF
---
apiVersion: v1
kind: Pod
metadata: {name: $1, namespace: %{ns}, labels: {app: stock, tier: $2}}
spec:
terminationGracePeriodSeconds: 1
nodeSelector: {"$4": "$5"}
containers: [{name: main, image: "$3", command: [sh, -c, "sleep 86400"]}]
EOF
}
{
pod stock-web-1 web busybox:1.36 kubernetes.io/hostname "$busy"
pod stock-web-2 web nginx:1.27 kubernetes.io/hostname "$busy"
pod stock-api-1 api redis:7-alpine kubernetes.io/hostname "$busy"
pod stock-web-3 web nginx:1.25 kubernetes.io/hostname "$o1"
pod stock-api-2 api busybox:1.36 kubernetes.io/hostname "$o2"
pod stock-web-4 web busybox:1.36 disktype nvme
pod stock-api-3 api nginx:1.27 disktype nvme
pod stock-api-4 api busybox:1.36 disktype nvme
} | $K apply -f - >/dev/null
$K -n %{ns} wait --for=condition=Ready pod/stock-web-1 pod/stock-web-2 pod/stock-api-1 pod/stock-web-3 pod/stock-api-2 --timeout=150s >/dev/null
""",
"checks": [
C("inventory.sh prints POD, NODE and IMAGE for every Pod of %{ns}", 3, r"""
f="$WS/q63/inventory.sh"; [ -s "$f" ] || exit 1
out=$(cd / && bash "$f" 2>/dev/null) || exit 1
want=$($K -n %{ns} get pods --no-headers -o 'custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image' | awk '{print $1, $2, $3}' | sort)
[ "$(echo "$want" | grep -c .)" -ge 8 ] || exit 1
eq "$(echo "$out" | head -1 | awk '{print $1, $2, $3, NF}')" "POD NODE IMAGE 3" || exit 1
echo "$out" | tail -n +2 | awk 'NF && NF != 3 {bad = 1} END {exit bad}' || exit 1
eq "$(echo "$out" | tail -n +2 | awk 'NF {print $1, $2, $3}' | sort)" "$want" """),
C("not-running.txt: the tier=%{tier} Pods that are not Running", 2, r"""
want=$($K -n %{ns} get pods -l tier=%{tier} --field-selector='status.phase!=Running' -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' | sort)
[ -n "$want" ] || exit 1
eq "$(tr -d ' \t\r' < "$WS/q63/not-running.txt" 2>/dev/null | sed 's#^pod/##' | grep . | sort)" "$want" """),
C("busiest.txt names the node with the most Pods", 2, r"""
want=$($K -n %{ns} get pods -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | grep . | sort | uniq -c | sort -rn | awk 'NR==1 {print $2}')
[ -n "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q63/busiest.txt" 2>/dev/null)" "$want" """),
],
"solution": """
echo "kubectl -n %{ns} get pods -o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image" > {WS}/q63/inventory.sh
bash {WS}/q63/inventory.sh # a Pod that was never scheduled shows <none> as its node
k -n %{ns} get pods -l tier=%{tier} --field-selector='status.phase!=Running' -o name | cut -d/ -f2 > {WS}/q63/not-running.txt
k -n %{ns} get pods -o wide --no-headers | awk '{print $7}' | sort | uniq -c | sort -rn # NODE is column 7 of -o wide
echo <the node on the first line> > {WS}/q63/busiest.txt
# -l filters on labels, --field-selector on a few fields of the object itself (status.phase,
# spec.nodeName, metadata.name ...), and both run on the server. custom-columns takes
# HEADER:jsonpath pairs without the {} that -o jsonpath needs.
""",
},
{
"id": 64, "domain": "OBS", "title": "One replica is logging errors",
"params": lambda r: {"ns": ns_pick(r, "trace"), "app": name_pick(r, "router"), "bad": r.choice([0, 1, 2]),
"code": r.choice([502, 503, 504]), "ver": r.choice(["2024.11", "2025.03", "2025.07"])},
"task": """
The three Pods of StatefulSet `%{app}` in Namespace `%{ns}` all log the requests they route, and
one of them logs errors as well.
1. Write the name of the Pod that logs `ERROR` lines to `{WS}/q64/bad-pod.txt`.
2. Save that Pod's `ERROR` lines, and nothing else, to `{WS}/q64/errors.log`.
3. Every Pod ran an init container `migrate` before it started. Write the schema version it
reported to `{WS}/q64/schema.txt`.
4. Write a command into `{WS}/q64/tail.sh` that prints the last **2** log lines of every Pod of
the StatefulSet in one go, each line prefixed with the Pod it comes from.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q64"; mkdir -p "$WS/q64"
$K -n %{ns} delete statefulset %{app} --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} delete pod -l app=%{app} --wait=true --grace-period=1 >/dev/null 2>&1
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: Service
metadata: {name: %{app}, namespace: %{ns}}
spec: {clusterIP: None, selector: {app: %{app}}, ports: [{port: 80}]}
---
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: %{app}, namespace: %{ns}}
spec:
serviceName: %{app}
replicas: 3
podManagementPolicy: Parallel
selector: {matchLabels: {app: %{app}}}
template:
metadata: {labels: {app: %{app}}}
spec:
terminationGracePeriodSeconds: 2
initContainers:
- name: migrate
image: busybox:1.36
command: [sh, -c, "echo 'checking schema'; echo 'schema version %{ver} is current'"]
containers:
- name: router
image: busybox:1.36
command:
- sh
- -c
- |
trap 'exit 0' TERM
i=0
while true; do
i=$((i+1))
echo "INFO request $i routed"
if [ "${HOSTNAME##*-}" = "%{bad}" ] && [ $((i % 3)) = 0 ]; then
echo "ERROR upstream timeout code=%{code} request=$i"
fi
sleep 2 & wait $!
done
EOF
$K -n %{ns} rollout status statefulset %{app} --timeout=150s >/dev/null
""",
"checks": [
C("bad-pod.txt names %{app}-%{bad}", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q64/bad-pod.txt" 2>/dev/null | sed 's#^pod/##')" %{app}-%{bad}"""),
C("errors.log holds its ERROR lines and nothing else", 2, r"""
f="$WS/q64/errors.log"; [ "$(grep -c . "$f" 2>/dev/null)" -ge 1 ] || exit 1
[ -z "$(grep . "$f" | grep -Ev 'ERROR upstream timeout code=%{code} request=[0-9]+[[:space:]]*$')" ]"""),
C("schema.txt holds %{ver}", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q64/schema.txt" 2>/dev/null)" %{ver}"""),
C("tail.sh prints the last 2 lines of all three Pods, prefixed with the Pod", 2, r"""
f="$WS/q64/tail.sh"; [ -s "$f" ] || exit 1
out=$(cd / && bash "$f" 2>/dev/null) || exit 1
for n in 0 1 2; do [ "$(echo "$out" | grep -c "^\[pod/%{app}-$n/router\] ")" = 2 ] || exit 1; done"""),
],
"solution": """
k -n %{ns} logs -l app=%{app} --prefix --tail=-1 | grep ERROR | head -3 # [pod/%{app}-%{bad}/router] ERROR ...
echo %{app}-%{bad} > {WS}/q64/bad-pod.txt
k -n %{ns} logs %{app}-%{bad} | grep ERROR > {WS}/q64/errors.log
k -n %{ns} logs %{app}-0 -c migrate # schema version %{ver} is current
echo %{ver} > {WS}/q64/schema.txt
echo "kubectl -n %{ns} logs -l app=%{app} --prefix --tail=2" > {WS}/q64/tail.sh
bash {WS}/q64/tail.sh
# With a selector, kubectl logs shows only the last 10 lines of each Pod unless --tail says
# otherwise (-1 = everything), and --prefix says which Pod and container a line came from.
# An init container's log is read like any other container's: -c NAME.
""",
},
# ------------------------------------------------------------------- ENV ----
{
"id": 59, "domain": "ENV", "title": "A custom resource the API server rejects",
"params": lambda r: (lambda eng, pair: {
"ns": ns_pick(r, "cache"), "name": name_pick(r, "sessions"), "eng": eng, "engcap": eng.capitalize(),
"mem": r.choice([256, 512, 1024]), "maxr": r.choice([3, 5, 7]), "bad1": pair[0], "bad2": pair[1]})(
r.choice(["redis", "valkey", "memcached"]), r.sample(["catalog", "ratelimit", "search", "tokens"], 2)),
"task": """
An operator for cache clusters runs in this cluster. Its CustomResourceDefinition belongs to the
API group `cache.example.com`.
1. Write the short name kubectl accepts for that kind to `{WS}/q59/shortname.txt`.
2. The operator reports the state of every cache cluster in `status.phase`. Write each cache
cluster in the **whole cluster** whose phase is `Degraded` to `{WS}/q59/degraded.txt`, one per
line as `<namespace>/<name>`.
3. `{WS}/q59/cache.yaml` is meant to create cache cluster `%{name}` in Namespace `%{ns}`, but the
API server rejects it. Correct the manifest and apply it: engine `%{eng}`, `%{mem}` MB of
memory per node, and as many replicas as the CRD allows.
""",
"setup": NS + r"""
rm -rf "$WS/q59"; mkdir -p "$WS/q59"
$K apply -f - >/dev/null <<'EOF'
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: cacheclusters.cache.example.com}
spec:
group: cache.example.com
scope: Namespaced
names: {plural: cacheclusters, singular: cachecluster, kind: CacheCluster, shortNames: [cc]}
versions:
- name: v1
served: true
storage: true
subresources: {status: {}}
additionalPrinterColumns:
- {name: Engine, type: string, jsonPath: .spec.engine}
- {name: Replicas, type: integer, jsonPath: .spec.replicas}
- {name: Phase, type: string, jsonPath: .status.phase}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [engine, replicas, memoryMb]
properties:
engine: {type: string, enum: [redis, valkey, memcached], description: "Cache engine"}
replicas: {type: integer, minimum: 1, maximum: %{maxr}, description: "Number of cache nodes"}
memoryMb: {type: integer, minimum: 64, maximum: 4096, description: "Memory per node, in MB"}
status:
type: object
properties:
phase: {type: string}
EOF
$K wait --for=condition=Established crd/cacheclusters.cache.example.com --timeout=60s >/dev/null
mkns %{ns}; mkns %{ns}-shared
$K delete cacheclusters.cache.example.com --all -A --wait=true >/dev/null 2>&1
mk() { # namespace name engine replicas memoryMb
phase=Ready; case "$2" in %{bad1}|%{bad2}) phase=Degraded ;; esac
printf 'apiVersion: cache.example.com/v1\nkind: CacheCluster\nmetadata: {name: %s, namespace: %s}\nspec: {engine: %s, replicas: %s, memoryMb: %s}\n' \
"$2" "$1" "$3" "$4" "$5" | $K apply -f - >/dev/null
$K -n "$1" patch cacheclusters.cache.example.com "$2" --subresource=status --type=merge -p "{\"status\":{\"phase\":\"$phase\"}}" >/dev/null
}
mk %{ns} catalog redis 2 256
mk %{ns}-shared ratelimit memcached 1 128
mk %{ns} search valkey 3 512
mk %{ns}-shared tokens redis 1 64
cat > "$WS/q59/cache.yaml" <<'EOF'
apiVersion: cache.example.com/v1alpha1
kind: CacheCluster
metadata:
name: %{name}
namespace: %{ns}
spec:
engine: %{engcap}
replicas: 12
memory: %{mem}
EOF
""",
"checks": [
C("shortname.txt holds cc", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q59/shortname.txt" 2>/dev/null)" cc"""),
C("degraded.txt lists exactly the Degraded cache clusters", 2, r"""
want=$($K get cacheclusters.cache.example.com -A -o jsonpath='{range .items[?(@.status.phase=="Degraded")]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}' 2>/dev/null | sort)
[ "$(echo "$want" | grep -c .)" = 2 ] || exit 1
eq "$(tr -d ' \t\r' < "$WS/q59/degraded.txt" 2>/dev/null | grep . | sort)" "$want" """),
C("CacheCluster %{name}: engine %{eng}, memoryMb %{mem}", 2, r"""
eq "$(j -n %{ns} cacheclusters.cache.example.com %{name} '{.spec.engine}/{.spec.memoryMb}')" "%{eng}/%{mem}" """),
C("it runs the most replicas the CRD allows", 1, r"""
max=$(j crd cacheclusters.cache.example.com '{.spec.versions[0].schema.openAPIV3Schema.properties.spec.properties.replicas.maximum}')
[ -n "$max" ] && eq "$(j -n %{ns} cacheclusters.cache.example.com %{name} '{.spec.replicas}')" "$max" """),
C("cache.yaml itself now applies", 1, r"""
f="$WS/q59/cache.yaml"; grep -Eq '^[[:space:]]*name:[[:space:]]*%{name}[[:space:]]*$' "$f" 2>/dev/null \
&& $K apply --dry-run=server -f "$f" >/dev/null 2>&1"""),
],
"solution": """
k api-resources --api-group=cache.example.com # cacheclusters cc cache.example.com/v1 true CacheCluster
echo cc > {WS}/q59/shortname.txt
k get cc -A # the PHASE column is status.phase
k get cc -A --no-headers | awk '$NF == "Degraded" {print $1 "/" $2}' > {WS}/q59/degraded.txt
k apply -f {WS}/q59/cache.yaml --dry-run=server # one complaint after another:
# no matches for kind "CacheCluster" in version "cache.example.com/v1alpha1" -> only v1 is served
# unknown field "spec.memory" -> the field is memoryMb
# spec.engine: Unsupported value: "%{engcap}": supported values: "redis", "valkey", "memcached"
# spec.replicas: Invalid value: 12: spec.replicas in body should be less than or equal to %{maxr}
k explain cc.spec # engine (enum: redis, valkey, memcached), memoryMb, replicas - all required
k get crd cacheclusters.cache.example.com -o yaml | grep -n -A1 'maximum'
vi {WS}/q59/cache.yaml
# apiVersion: cache.example.com/v1
# spec: {engine: %{eng}, replicas: %{maxr}, memoryMb: %{mem}}
k apply -f {WS}/q59/cache.yaml
# A CRD's schema is the contract: required fields, enum values and bounds are all in it, and
# --dry-run=server shows every violation without creating anything. .status belongs to the
# operator - you read it, you do not write it.
""",
},
# ---------------------------------------------------------------- DESIGN ----
{
"id": 60, "domain": "DESIGN", "title": "Entrypoint, default argument and a non-root user",
"params": lambda r: {"img": name_pick(r, r.choice(["reporter", "exporter", "notifier"])), "arg": r.choice(["daily", "weekly", "hourly"]),
"arg2": r.choice(["audit", "billing", "usage"]), "team": r.choice(["payments", "search", "identity"])},
"task": """
`{WS}/q60/image` is the build context of `localhost/%{img}:1.0`, which is already built on this
machine (use `podman`). That image runs as root, and running it with an argument fails.
1. Change the **Dockerfile** so that
- `/app/report.sh` is the image's entrypoint and `%{arg}` its default argument,
- the image runs as the non-root user `10001`,
- the image carries the label `team=%{team}`,
and build it as `localhost/%{img}:1.1`.
2. Run `1.1` once with its defaults and write the output to `{WS}/q60/default.txt`. Run it once
more with the argument `%{arg2}` instead and write that output to `{WS}/q60/%{arg2}.txt`.
3. Remove the old image `localhost/%{img}:1.0` from this machine.
""",
"setup": r"""
command -v podman >/dev/null || { echo "podman is not installed on this machine - the task needs it"; exit 1; }
podman rmi -f localhost/%{img}:1.0 localhost/%{img}:1.1 >/dev/null 2>&1 || true
rm -rf "$WS/q60"; mkdir -p "$WS/q60/image"
cat > "$WS/q60/image/Dockerfile" <<'EOF'
FROM docker.io/library/busybox:1.36
COPY report.sh /app/report.sh
RUN chmod 0755 /app/report.sh
CMD ["/app/report.sh", "all"]
EOF
cat > "$WS/q60/image/report.sh" <<'EOF'
#!/bin/sh
echo "%{img} report: ${1:-none} (uid $(id -u))"
EOF
podman build -q -t localhost/%{img}:1.0 "$WS/q60/image" >/dev/null 2>&1 \
|| { echo "could not build localhost/%{img}:1.0 - is docker.io/library/busybox:1.36 available to podman?"; exit 1; }
""",
"checks": [
C("1.1 runs /app/report.sh with %{arg} by default, and with another argument when given one", 2, r"""
eq "$(podman run --rm localhost/%{img}:1.1 2>/dev/null | cut -d'(' -f1)" "%{img} report: %{arg} " || exit 1
eq "$(podman run --rm localhost/%{img}:1.1 probe 2>/dev/null | cut -d'(' -f1)" "%{img} report: probe " """),
C("1.1 runs as user 10001 and carries the label team=%{team}", 2, r"""
podman run --rm localhost/%{img}:1.1 2>/dev/null | grep -q '(uid 10001)$' || exit 1
eq "$(podman image inspect localhost/%{img}:1.1 --format '{{index .Config.Labels "team"}}' 2>/dev/null)" %{team}"""),
C("the Dockerfile itself sets ENTRYPOINT, USER and LABEL", 1, r"""
f="$WS/q60/image/Dockerfile"
grep -Eiq '^ENTRYPOINT' "$f" 2>/dev/null && grep -Eiq '^USER[[:space:]]+10001' "$f" && grep -Eiq '^LABEL.*team' "$f" """),
C("default.txt and %{arg2}.txt hold the two outputs", 1, r"""
eq "$(tr -d '\r' < "$WS/q60/default.txt" 2>/dev/null)" "%{img} report: %{arg} (uid 10001)" \
&& eq "$(tr -d '\r' < "$WS/q60/%{arg2}.txt" 2>/dev/null)" "%{img} report: %{arg2} (uid 10001)" """),
C("localhost/%{img}:1.0 is gone, 1.1 is there", 1, r"""
podman image exists localhost/%{img}:1.1 && ! podman image exists localhost/%{img}:1.0"""),
],
"solution": """
cd {WS}/q60/image
podman run --rm localhost/%{img}:1.0 %{arg2} # fails: with only a CMD, the argument replaces the whole command
vi Dockerfile
# FROM docker.io/library/busybox:1.36
# LABEL team=%{team}
# COPY report.sh /app/report.sh
# RUN chmod 0755 /app/report.sh
# USER 10001
# ENTRYPOINT ["/app/report.sh"]
# CMD ["%{arg}"]
podman build -t localhost/%{img}:1.1 .
podman run --rm localhost/%{img}:1.1 > {WS}/q60/default.txt # %{img} report: %{arg} (uid 10001)
podman run --rm localhost/%{img}:1.1 %{arg2} > {WS}/q60/%{arg2}.txt
podman rmi localhost/%{img}:1.0
podman inspect localhost/%{img}:1.1 --format '{{.Config.User}} {{.Config.Entrypoint}} {{.Config.Cmd}} {{.Config.Labels}}'
# ENTRYPOINT is the program, CMD its default arguments, and what follows the image name on the
# command line replaces CMD only. Both need the exec form (a JSON array): a shell-form ENTRYPOINT
# ignores CMD and arguments. In a Pod the same pair is `command` (ENTRYPOINT) and `args` (CMD).
# USER goes after the RUN steps that need root.
""",
},
# ---------------------------------------------------------------- DEPLOY ----
{
"id": 61, "domain": "DEPLOY", "title": "A kustomization that does not build",
"params": lambda r: {"ns": ns_pick(r, "shop"), "sfx": r.choice(["eu", "us", "apac"]), "tok": name_pick(r, "tok"), "n": r.choice([2, 3])},
"task": """
`{WS}/q61/app` holds a kustomization for the app `storefront`, but
`kubectl kustomize {WS}/q61/app` fails.
1. Make it build. Two things are wrong. The Deployment and the Service are fine as they are - do
not change `deployment.yaml` or `service.yaml`.
2. Extend `kustomization.yaml` so that the result
- goes into Namespace `%{ns}`,
- has every resource name end in `-%{sfx}`,
- runs image `nginx:1.27`,
- includes a generated Secret `storefront-token` whose key `token` is `%{tok}`, **without** a
hash at the end of its name.
3. Apply it. The Deployment must come up with the %{n} replicas its patch asks for.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy/storefront-%{sfx} svc/storefront-%{sfx} secret/storefront-token-%{sfx} --ignore-not-found >/dev/null 2>&1
rm -rf "$WS/q61"; mkdir -p "$WS/q61/app"
cat > "$WS/q61/app/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: storefront, labels: {app: storefront}}
spec:
replicas: 1
selector: {matchLabels: {app: storefront}}
template:
metadata: {labels: {app: storefront}}
spec:
containers:
- name: web
image: nginx:1.25
ports: [{containerPort: 80}]
env:
- name: API_TOKEN
valueFrom: {secretKeyRef: {name: storefront-token, key: token}}
EOF
cat > "$WS/q61/app/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata: {name: storefront, labels: {app: storefront}}
spec: {selector: {app: storefront}, ports: [{port: 80, targetPort: 80}]}
EOF
cat > "$WS/q61/app/replicas-patch.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: store-front
spec:
replicas: %{n}
EOF
cat > "$WS/q61/app/kustomization.yaml" <<'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deploy.yaml
- service.yaml
patches:
- path: replicas-patch.yaml
EOF
(cd "$WS/q61/app" && md5sum deployment.yaml service.yaml) > "$WS/.q61-app.md5"
""",
"checks": [
C("builds: Deployment and Service storefront-%{sfx} in %{ns}; %{n} replicas of nginx:1.27", 3, r"""
$K kustomize "$WS/q61/app" 2>/dev/null | $K create --dry-run=client -o json -f - 2>/dev/null | python3 -c '
import sys, json
s = sys.stdin.read(); dec = json.JSONDecoder(); i = 0; items = []
while i < len(s):
while i < len(s) and s[i].isspace(): i += 1
if i >= len(s): break
o, i = dec.raw_decode(s, i); items.extend(o.get("items", [o]))
dep = [x for x in items if x["kind"] == "Deployment"]; svc = [x for x in items if x["kind"] == "Service"]
ok = len(dep) == 1 and len(svc) == 1 and all(x["metadata"].get("namespace") == "%{ns}" for x in items) \
and dep[0]["metadata"]["name"] == "storefront-%{sfx}" and svc[0]["metadata"]["name"] == "storefront-%{sfx}" \
and dep[0]["spec"]["replicas"] == %{n} and dep[0]["spec"]["template"]["spec"]["containers"][0]["image"] == "nginx:1.27"
sys.exit(0 if ok else 1)'"""),
C("generated Secret storefront-token-%{sfx} (no hash) holds the token, and the Deployment refers to it", 2, r"""
$K kustomize "$WS/q61/app" 2>/dev/null | $K create --dry-run=client -o json -f - 2>/dev/null | python3 -c '
import sys, json, base64
s = sys.stdin.read(); dec = json.JSONDecoder(); i = 0; items = []
while i < len(s):
while i < len(s) and s[i].isspace(): i += 1
if i >= len(s): break
o, i = dec.raw_decode(s, i); items.extend(o.get("items", [o]))
sec = [x for x in items if x["kind"] == "Secret"]; dep = [x for x in items if x["kind"] == "Deployment"]
ok = len(sec) == 1 and len(dep) == 1 and sec[0]["metadata"]["name"] == "storefront-token-%{sfx}" \
and base64.b64decode(sec[0].get("data", {}).get("token", "")).decode() == "%{tok}" \
and dep[0]["spec"]["template"]["spec"]["containers"][0]["env"][0]["valueFrom"]["secretKeyRef"]["name"] == "storefront-token-%{sfx}"
sys.exit(0 if ok else 1)'"""),
C("applied: %{n} Pods available, and they see API_TOKEN=%{tok}", 2, r"""
d="$K -n %{ns} get deploy storefront-%{sfx}"; ok=
for i in $(seq 1 30); do [ "$($d -o jsonpath='{.status.availableReplicas}' 2>/dev/null)" = %{n} ] && { ok=1; break; }; sleep 3; done
[ -n "$ok" ] || exit 1
eq "$($K -n %{ns} exec deploy/storefront-%{sfx} -- printenv API_TOKEN 2>/dev/null | tr -d '\r')" %{tok}"""),
C("deployment.yaml and service.yaml left as they were", 1, r"""
[ -n "$($K kustomize "$WS/q61/app" 2>/dev/null)" ] && (cd "$WS/q61/app" && md5sum -c --quiet "$WS/.q61-app.md5" >/dev/null 2>&1)"""),
],
"solution": """
cd {WS}/q61/app
k kustomize . # ... deploy.yaml: no such file or directory -> the file is deployment.yaml
k kustomize . # no matches for Id Deployment...store-front; failed to find unique target for patch
sed -i 's/name: store-front/name: storefront/' replicas-patch.yaml # a patch finds its target by kind and name
# (or leave the file and name the target in kustomization.yaml: target: {kind: Deployment, name: storefront})
cat > kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
patches:
- path: replicas-patch.yaml
namespace: %{ns}
nameSuffix: -%{sfx}
images:
- {name: nginx, newTag: "1.27"}
secretGenerator:
- name: storefront-token
literals: [token=%{tok}]
generatorOptions:
disableNameSuffixHash: true
EOF
k kustomize . # read it: storefront-%{sfx} x3 kinds, and secretKeyRef.name became storefront-token-%{sfx}
k apply -k .
k -n %{ns} get deploy,svc,secret
# Patches, images and replicas use the names in the files; nameSuffix is applied afterwards, to
# the generated Secret as well, and every reference to a renamed object is rewritten for you.
# Without disableNameSuffixHash the Secret would be storefront-token-%{sfx}-<hash>.
""",
},
]
# written grouped by domain, listed by number
QUESTIONS.sort(key=lambda q: q["id"])

View File

@ -1,701 +0,0 @@
"""CKAD curriculum as a study path: the 24 objectives of the five domains, in the curriculum's own
words and order (CKAD_Curriculum_v1.37.pdf, cncf/curriculum), each with what to know, the fastest
commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it.
./exam objectives the path, with your progress per objective
./exam notes 3.2 the notes for one objective (id or key: 3.2, probes)
./exam study 3.2 those notes, then its tasks with no clock and solutions visible
./exam drill 3.2 the same tasks against the clock
"tasks" lists every task that really exercises the objective, most direct first; a task may sit
under several objectives. Study and drill sessions use the pool tasks; the Simulator Set A tasks
(38-57) are listed too but stay out unless you ask for them (--all), so the mock stays unspoiled.
Every task must be listed at least once and every objective needs two pool tasks -
`./exam objectives` says so when that stops being true.
"""
OBJECTIVES = [
# ------------------------------------------------- 1. Application Design and Build (20%) ----
{
"id": "1.1", "key": "images", "domain": "DESIGN",
"text": "Define, build and modify container images",
"tasks": [13, 60, 41],
"know": [
"Read and change a Dockerfile: FROM, COPY, RUN, ENV, ARG (build time only), WORKDIR, USER, LABEL, EXPOSE, ENTRYPOINT, CMD.",
"ENTRYPOINT is the program, CMD its default arguments. In a Pod, `command` replaces ENTRYPOINT and `args` replaces CMD.",
"Build, tag, list, inspect, run, save and remove images. The exam terminal has docker or podman - the verbs are the same.",
"A tag is only a pointer: one image can carry several tags, and rebuilding under an old tag moves it.",
],
"fast": """
podman build -t localhost/app:v1 . # -f path/Dockerfile --build-arg KEY=val
podman tag localhost/app:v1 localhost/app:latest
podman images | grep app
podman inspect localhost/app:v1 --format '{{.Config.User}} {{.Config.Entrypoint}} {{.Config.Cmd}} {{.Config.Env}}'
podman run --rm localhost/app:v1 [args replacing CMD] # -d --name x keeps it running; podman logs x
podman save --format oci-archive -o app.tar localhost/app:v1 # docker save -o app.tar ...
podman rmi localhost/app:v0
""",
"traps": [
"An ARG is gone at run time unless you copy it into an ENV (`ARG V` then `ENV APP_VERSION=$V`).",
"`podman save` writes a docker-archive unless you pass `--format oci-archive`.",
"Build before you edit when a task wants the old version kept under its own tag.",
"imagePullPolicy defaults to Always for `:latest` (or no tag) and IfNotPresent for any other tag.",
],
"docs": ["https://kubernetes.io/docs/concepts/containers/images/",
"https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/"],
},
{
"id": "1.2", "key": "workloads", "domain": "DESIGN",
"text": "Choose and use the right workload resource (Deployment, DaemonSet, CronJob, etc.)",
"tasks": [3, 14, 15, 39, 38, 44, 56],
"know": [
"Choose by behaviour: Deployment (interchangeable replicas, rolling updates), StatefulSet (stable name, DNS and storage per replica), DaemonSet (one Pod per node), Job (run to completion), CronJob (Jobs on a schedule), bare Pod (one-off).",
"Job: completions, parallelism, backoffLimit, activeDeadlineSeconds, ttlSecondsAfterFinished; restartPolicy must be Never or OnFailure.",
"CronJob: schedule, startingDeadlineSeconds, concurrencyPolicy (Allow, Forbid, Replace), suspend, successfulJobsHistoryLimit, failedJobsHistoryLimit.",
"DaemonSet: no replicas; a toleration for node-role.kubernetes.io/control-plane:NoSchedule puts it on control-plane nodes too.",
],
"fast": """
k create deploy web --image=nginx:1.27 --replicas=3 $do > deploy.yaml
k create job render --image=busybox:1.36 $do -- sh -c 'echo done' > job.yaml
k create cronjob digest --image=busybox:1.36 --schedule='*/5 * * * *' $do -- sh -c 'echo digest' > cj.yaml
k create job digest-manual --from=cronjob/digest # run a CronJob once, now
k run tmp --image=busybox:1.36 --restart=Never --rm -it -- sh
# DaemonSet has no generator: create a Deployment with $do, change kind, delete replicas/strategy/status
k explain cronjob.spec.jobTemplate.spec --recursive | less
""",
"traps": [
"Three nested specs in a CronJob: spec (schedule, startingDeadlineSeconds) > jobTemplate.spec (completions, activeDeadlineSeconds) > template.spec (containers, restartPolicy).",
"A Job's Pod template is immutable - delete the Job and create it again instead of editing it.",
"A StatefulSet needs `serviceName` naming a headless Service (clusterIP: None) before its Pods get their own DNS names.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/job/",
"https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/",
"https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/"],
},
{
"id": "1.3", "key": "multicontainer", "domain": "DESIGN",
"text": "Understand multi-container Pod design patterns (e.g. sidecar, init and others)",
"tasks": [1, 2, 16, 40],
"know": [
"Containers of a Pod share its network (they reach each other on localhost) and any volume both mount - that is all a sidecar, adapter or ambassador is.",
"Init containers run one at a time, in order, each to completion, before any app container starts.",
"A native sidecar is an init container with `restartPolicy: Always`: it starts before the app containers, keeps running beside them, and does not keep a Job from completing.",
"Address one container with `-c`: `k logs POD -c NAME`, `k exec POD -c NAME -- ...`. Status `Init:0/1` means an init container is still running.",
],
"fast": """
k run app --image=nginx:1.27 $do > pod.yaml # then add the second container by hand
spec:
initContainers:
- {name: wait, image: busybox:1.36, command: [sh, -c, "until wget -qO- -T2 http://backend; do sleep 2; done"]}
- {name: shipper, image: busybox:1.36, restartPolicy: Always, command: [sh, -c, "tail -F /logs/app.log"],
volumeMounts: [{name: logs, mountPath: /logs}]} # native sidecar
containers:
- {name: app, image: nginx:1.27, volumeMounts: [{name: logs, mountPath: /var/log/app}]}
volumes: [{name: logs, emptyDir: {}}]
k logs app -c shipper; k logs app --all-containers --prefix
""",
"traps": [
"`kubectl run` creates one container only - generate the Pod and add the others in the YAML.",
"A Service with no endpoints still resolves in DNS, so an init container waiting on `nslookup` is not waiting for the app. Wait on a real request.",
"A classic sidecar that never exits keeps a Job running for ever; make it a native sidecar.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/pods/init-containers/",
"https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/"],
},
{
"id": "1.4", "key": "volumes", "domain": "DESIGN",
"text": "Utilize persistent and ephemeral volumes",
"tasks": [17, 10, 52, 40, 50],
"know": [
"Ephemeral: emptyDir (lives as long as the Pod; `medium: Memory` is tmpfs, `sizeLimit` caps it), and the configMap, secret, downwardAPI and projected volumes.",
"Persistent: a PersistentVolumeClaim asks for storage (accessModes, resources.requests.storage, storageClassName); a StorageClass provisions the PersistentVolume, or a pre-made one binds.",
"Access modes: ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod.",
"volumeMounts: mountPath, readOnly, and subPath to mount one file or directory without hiding the rest of the target directory.",
],
"fast": """
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: data}
spec:
accessModes: [ReadWriteOnce]
storageClassName: standard
resources: {requests: {storage: 100Mi}}
--- # in the Pod spec:
volumes:
- {name: data, persistentVolumeClaim: {claimName: data}}
- {name: scratch, emptyDir: {medium: Memory, sizeLimit: 32Mi}}
containers:
- volumeMounts: [{name: data, mountPath: /data}, {name: scratch, mountPath: /scratch}]
k get pvc,pv; k describe pvc data # Pending? the Events say why
""",
"traps": [
"There is no `kubectl create pvc` - copy the claim from the docs.",
"With a WaitForFirstConsumer StorageClass (`standard` here) a claim stays Pending until a Pod uses it. That is not an error.",
"emptyDir survives a container restart but not a new Pod. To prove data persists, delete the Pod and read the file again.",
"A non-root container cannot write to a fresh volume unless the Pod sets `securityContext.fsGroup`.",
],
"docs": ["https://kubernetes.io/docs/concepts/storage/persistent-volumes/",
"https://kubernetes.io/docs/concepts/storage/volumes/",
"https://kubernetes.io/docs/tutorials/configuration/configure-persistent-volume-storage/"],
},
# ------------------------------------------------------- 2. Application Deployment (20%) ----
{
"id": "2.1", "key": "strategies", "domain": "DEPLOY",
"text": "Use Kubernetes primitives to implement common deployment strategies (e.g. blue/green or canary)",
"tasks": [5, 18],
"know": [
"Blue/green: two Deployments, and the Service selector decides which one gets all the traffic. Cut over by changing the selector; roll back by changing it back.",
"Canary: two Deployments whose Pods share the label the Service selects. Traffic splits by the number of ready Pods: 1 canary beside 4 stable is 20%.",
"A Deployment's own strategy is RollingUpdate (default) or Recreate (every old Pod stops first - downtime).",
],
"fast": """
k get svc web -o wide; k get pods --show-labels
k set selector svc web 'app=web,version=green' # blue/green cut-over
k scale deploy web-stable --replicas=4 # canary ratio: 4 stable + 1 canary = 20%
k get endpointslices -l kubernetes.io/service-name=web -o wide
k get pods -l app=web -L version,track
""",
"traps": [
"Wait until the new Deployment is fully available before you switch the selector.",
"`kubectl set selector` replaces the whole selector - name every label you want in it.",
"A canary only shares traffic if the Service selector leaves out the label that tells the two versions apart.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/management/",
"https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"],
},
{
"id": "2.2", "key": "rollouts", "domain": "DEPLOY",
"text": "Understand Deployments and how to perform rolling updates",
"tasks": [4, 19, 42, 44],
"know": [
"Any change to `spec.template` starts a rollout: a new ReplicaSet scales up while the old one scales down, within maxSurge and maxUnavailable (25% each by default).",
"minReadySeconds (how long a Pod must be Ready to count as available), revisionHistoryLimit (old ReplicaSets kept), progressDeadlineSeconds.",
"`kubectl rollout`: status, history, undo, pause, resume, restart.",
],
"fast": """
k set image deploy/web nginx=nginx:1.27 # CONTAINER=IMAGE
k rollout status deploy/web
k rollout history deploy/web # --revision=3 shows that revision's template
k rollout undo deploy/web # --to-revision=2
k rollout restart deploy/web # same spec, new Pods
k annotate deploy/web kubernetes.io/change-cause='pick 1.27'
k patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}'
""",
"traps": [
"`--record` is deprecated: write the kubernetes.io/change-cause annotation yourself.",
"An undo makes a new revision out of the old one, so the active revision number is not the one you rolled back to.",
"A rollout stuck on a bad image does not fail by itself - `rollout status` just waits, and the old Pods keep serving.",
"maxSurge and maxUnavailable cannot both be 0.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"],
},
{
"id": "2.3", "key": "helm", "domain": "DEPLOY",
"text": "Use the Helm package manager to deploy existing packages",
"tasks": [6, 21, 43],
"know": [
"A chart is the package, a release is one installation of it in a Namespace, and every install, upgrade or rollback adds a revision.",
"Values: the chart's defaults (`helm show values`), overridden with `-f file.yaml` and `--set key=value`; `helm get values` shows what a release was given.",
"Life cycle: repo add/update, search, install, upgrade, rollback, uninstall, list, history, status.",
],
"fast": """
helm repo add NAME URL && helm repo update; helm search repo nginx --versions
helm show values CHART > values.yaml
helm install REL CHART -n NS --create-namespace -f values.yaml --set replicaCount=2 # --version X
helm upgrade REL CHART -n NS --reuse-values --set replicaCount=4
helm list -n NS # -A for every Namespace; --failed, --pending filter by status
helm history REL -n NS; helm rollback REL 1 -n NS; helm uninstall REL -n NS
helm get values REL -n NS # --all adds the defaults; helm get manifest REL -n NS
""",
"traps": [
"Releases live in a Namespace: without `-n` Helm looks in `default` and finds nothing.",
"An upgrade without `--reuse-values` (or the same `-f`/`--set` again) puts every value back to the chart's default.",
"A rollback is a new revision, not a step back in the numbering.",
"Helm 3's `helm list` leaves out pending releases unless you add `-a`; Helm 4 (on this machine) lists every status and has no `-a`. `--pending` and `--failed` work in both.",
],
"docs": ["https://helm.sh/docs/intro/using_helm/", "https://helm.sh/docs/helm/helm_upgrade/"],
},
{
"id": "2.4", "key": "kustomize", "domain": "DEPLOY",
"text": "Kustomize",
"tasks": [20, 61, 45],
"know": [
"A kustomization.yaml lists `resources` (files or other kustomizations) and transforms them: namespace, namePrefix, nameSuffix, labels, images, replicas, patches.",
"Generators build ConfigMaps and Secrets from files, env files or literals, add a content hash to the name and rewrite every reference to it.",
"Base and overlays: an overlay lists `../../base` under resources and changes only what differs.",
"`kubectl kustomize DIR` prints the result; `kubectl apply -k DIR` applies it.",
],
"fast": """
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: [../../base] # or files: [deployment.yaml, service.yaml]
namespace: prod
namePrefix: prod-
labels: [{pairs: {env: prod}, includeSelectors: false}]
images: [{name: nginx, newTag: "1.27"}]
replicas: [{name: web, count: 3}]
patches: [{path: patch.yaml}] # a partial Deployment with the same name
configMapGenerator: [{name: settings, envs: [settings.env]}]
secretGenerator: [{name: token, literals: [token=abc]}]
generatorOptions: {disableNameSuffixHash: true}
k kustomize DIR | less; k apply -k DIR; k diff -k DIR
""",
"traps": [
"Always read the render before you apply it.",
"`images`, `replicas` and patches name things as the base does, before any prefix or suffix.",
"Refer to a generated ConfigMap by its plain name - Kustomize appends the hash everywhere it is used.",
"`namespace:` does not create the Namespace.",
],
"docs": ["https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/"],
},
# --------------------------------- 3. Application Observability and Maintenance (15%) ----
{
"id": "3.1", "key": "deprecations", "domain": "OBS",
"text": "Understand API deprecations",
"tasks": [22, 58],
"know": [
"Every object is a kind in an API group at a version (apps/v1 Deployment). Versions are deprecated, keep being served for some releases, then are removed.",
"What this cluster serves: `kubectl api-resources` (the APIVERSION column), `kubectl api-versions`, `kubectl explain KIND`.",
"A removed version fails with `no matches for kind \"X\" in version \"g/v\"`; a deprecated one still works and prints `Warning: ... is deprecated`.",
"Migrating is the new apiVersion plus whatever changed shape: an Ingress backend becomes `service: {name, port: {number}}` with a required pathType, and an apps/v1 Deployment needs `spec.selector`.",
],
"fast": """
k api-resources | grep -i -E 'cronjob|ingress' # NAME SHORTNAMES APIVERSION NAMESPACED KIND
k api-versions | grep batch
k explain hpa | head -4 # GROUP, KIND, VERSION
k apply -f old.yaml --dry-run=server # the error or warning, without changing anything
# removed, and what replaced them:
# extensions/v1beta1, networking.k8s.io/v1beta1 Ingress -> networking.k8s.io/v1 (1.22)
# extensions/v1beta1, apps/v1beta1, apps/v1beta2 Deployment -> apps/v1 (1.16)
# batch/v1beta1 CronJob -> batch/v1 (1.25)
# policy/v1beta1 PodDisruptionBudget -> policy/v1 (1.25)
# autoscaling/v2beta1, v2beta2 HorizontalPodAutoscaler -> autoscaling/v2 (1.25, 1.26)
""",
"traps": [
"The `kubectl convert` plugin is usually not installed - know the Ingress and Deployment rewrites by hand.",
"`kubectl get` shows an object in the API's preferred version whatever version it was written with.",
"A warning is not an error: the object was created. Read stderr.",
],
"docs": ["https://kubernetes.io/docs/reference/using-api/deprecation-guide/",
"https://kubernetes.io/docs/reference/deprecation-policy/"],
},
{
"id": "3.2", "key": "probes", "domain": "OBS",
"text": "Implement probes and health checks",
"tasks": [7, 62, 46],
"know": [
"Liveness: failing restarts the container. Readiness: failing takes the Pod out of Service endpoints, no restart. Startup: holds the other two off until it has passed once.",
"Handlers: httpGet (path, port), tcpSocket (port), exec (command), grpc (port).",
"Timing: initialDelaySeconds (0), periodSeconds (10), timeoutSeconds (1), failureThreshold (3), successThreshold (1). A startup probe allows failureThreshold x periodSeconds to come up.",
],
"fast": """
containers:
- name: app
startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12} # 60 s to start
readinessProbe: {httpGet: {path: /ready, port: 8080}, periodSeconds: 5, failureThreshold: 1}
livenessProbe: {tcpSocket: {port: 80}, initialDelaySeconds: 15, periodSeconds: 10}
# exec: readinessProbe: {exec: {command: [cat, /tmp/ready]}, periodSeconds: 5}
k describe pod POD | grep -E 'Liveness|Readiness|Startup'
k get events --field-selector reason=Unhealthy
k explain pod.spec.containers.livenessProbe
""",
"traps": [
"No kubectl flag adds a probe: generate the YAML and edit it, or `kubectl edit`.",
"Probes belong to a container, not to the Pod spec.",
"Restarts climbing with `Unhealthy` and `Killing` events but nothing wrong in the logs: the liveness probe is wrong or too impatient, not the app.",
"Running, 0/1 Ready and no restarts: the readiness probe is failing.",
],
"docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/",
"https://kubernetes.io/docs/concepts/workloads/pods/probes/"],
},
{
"id": "3.3", "key": "monitoring", "domain": "OBS",
"text": "Use built-in CLI tools to monitor Kubernetes applications",
"tasks": [23, 63, 48, 38],
"know": [
"`kubectl get` output: -o wide, yaml, json, name, jsonpath=, custom-columns=; narrowed with -l (labels), --field-selector, -n / -A; ordered with --sort-by.",
"`kubectl top pod|node` shows live CPU and memory from metrics-server; `--containers` splits a Pod up.",
"`kubectl describe` for the story of one object, `kubectl get events` for the Namespace.",
],
"fast": """
k top pod -A --sort-by=memory; k top pod --containers; k top node
k get pods -o wide --sort-by=.spec.nodeName
k get pods -o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image
k get pod POD -o jsonpath='{.status.phase}'
k get pods -l tier=web --field-selector=status.phase!=Running -o name
k get pods -A --sort-by='.status.containerStatuses[0].restartCount'
k get events --sort-by=.lastTimestamp; k events --types=Warning --for pod/POD
""",
"traps": [
"`kubectl get --sort-by` sorts ascending (the largest is last); `kubectl top --sort-by` sorts descending (the largest is first).",
"`kubectl top` shows usage, not requests or limits, and needs about a minute after a Pod starts.",
"Quote jsonpath for the shell: '{.items[*].metadata.name}'.",
],
"docs": ["https://kubernetes.io/docs/reference/kubectl/quick-reference/",
"https://kubernetes.io/docs/reference/kubectl/jsonpath/"],
},
{
"id": "3.4", "key": "logs", "domain": "OBS",
"text": "Utilize container logs",
"tasks": [24, 64, 49, 54, 39],
"know": [
"`kubectl logs` shows what a container wrote to stdout and stderr. The kubelet keeps it on the node, and it goes when the Pod is deleted.",
"Pick the container with -c (init containers too), or take --all-containers; --previous is the run before the last restart.",
"Narrow with --tail=N, --since=5m, --timestamps; follow with -f; by label with -l.",
],
"fast": """
k logs POD -c CONTAINER # --all-containers --prefix
k logs POD --previous # the crashed run
k logs POD --tail=20 --since=10m --timestamps
k logs -l app=web --prefix --tail=5 # every Pod with the label
k logs deploy/web; k logs job/render # one Pod of it
k logs POD | grep ERROR > errors.log
""",
"traps": [
"With -l, only the last 10 lines of each Pod are shown unless you set --tail (--tail=-1 for everything).",
"`kubectl logs deploy/x` reads one Pod, not all replicas.",
"--previous fails when the container has never restarted.",
],
"docs": ["https://kubernetes.io/docs/concepts/cluster-administration/logging/",
"https://kubernetes.io/docs/reference/kubectl/quick-reference/"],
},
{
"id": "3.5", "key": "debugging", "domain": "OBS",
"text": "Debugging in Kubernetes",
"tasks": [8, 25, 26, 36, 47, 48],
"know": [
"Always the same order: get (status), describe (Events at the bottom), logs (--previous), then exec or debug.",
"Pending: nothing can schedule it (resources, taints, nodeSelector, an unbound claim). ImagePullBackOff / ErrImagePull: wrong image or tag. CreateContainerConfigError: a ConfigMap, Secret or key it references is missing.",
"CrashLoopBackOff: the process exits - read the previous log and lastState.terminated (reason, exitCode). OOMKilled (137): the memory limit is too low.",
"A Deployment with no Pods at all: describe its ReplicaSet - a quota, a LimitRange or Pod Security admission is rejecting them.",
],
"fast": """
k get pods -o wide; k describe pod POD | tail -20
k get pod POD -o jsonpath='{.status.containerStatuses[0].state.waiting.reason}'
k get pod POD -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}' # .exitCode
k logs POD --previous; k exec -it POD -- sh
k debug -it POD --image=busybox:1.36 --target=CONTAINER # ephemeral container, sees its processes
k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://SVC:PORT
k describe rs -l app=web --show-events | tail; k get events --sort-by=.lastTimestamp
k get pod POD -o yaml > pod.yaml && vi pod.yaml && k replace --force -f pod.yaml
""",
"traps": [
"Most of a running Pod is immutable (the image is not): fix a bare Pod with `replace --force`, fix a Deployment's Pods in the Deployment.",
"Fix what the task names and nothing else - graders check that the ConfigMap, Service or Namespace you were told to leave is unchanged.",
"After fixing a Deployment wait for the rollout; old Pods linger in Terminating.",
"`kubectl describe` with a selector that matches several objects leaves their Events out - add `--show-events`, or describe one by name.",
],
"docs": ["https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/",
"https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/",
"https://kubernetes.io/docs/tasks/debug/debug-application/determine-reason-pod-failure/"],
},
# -------------- 4. Application Environment, Configuration and Security (25%) ----
{
"id": "4.1", "key": "crds", "domain": "ENV",
"text": "Discover and use resources that extend Kubernetes (CRD, Operators)",
"tasks": [27, 59, 58],
"know": [
"A CustomResourceDefinition adds a kind to the API; its objects (custom resources) work with every kubectl verb. An operator is a CRD plus a controller acting on those objects.",
"Discover: `k get crd`, `k api-resources --api-group=GROUP` (kind, short names, namespaced or not), `k explain KIND.spec`.",
"The CRD's schema validates custom resources: required fields, enum values, minimum and maximum. The controller reports back in `.status`.",
],
"fast": """
k get crd | grep -i backup # the CRD's name is PLURAL.GROUP
k api-resources --api-group=data.example.com
k explain backup.spec # --recursive for the whole tree
k get backups -A; k get bkp NAME -o yaml; k describe backup NAME
k get crd backups.data.example.com -o yaml | less # served versions, schema, enum, maximum
k apply -f cr.yaml --dry-run=server # every validation error, nothing created
""",
"traps": [
"A custom resource's apiVersion is the CRD's GROUP/VERSION, never apiextensions.k8s.io/v1.",
"Field names come from the CRD's schema - check with `kubectl explain` instead of guessing.",
"Deleting a CRD deletes every object of that kind.",
],
"docs": ["https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/",
"https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/",
"https://kubernetes.io/docs/concepts/extend-kubernetes/operator/"],
},
{
"id": "4.2", "key": "access", "domain": "ENV",
"text": "Understand authentication, authorization and admission control",
"tasks": [28, 29, 49, 53],
"know": [
"Every request passes authentication (who is it: a user certificate or token, a ServiceAccount token), authorization (may they: RBAC), then admission control (change or reject it: LimitRanger, ResourceQuota, PodSecurity).",
"RBAC: a Role or ClusterRole lists verbs on resources (and subresources such as pods/log, deployments/scale); a RoleBinding or ClusterRoleBinding grants it to users, groups or ServiceAccounts.",
"Pod Security admission: Namespace labels pod-security.kubernetes.io/enforce|warn|audit = privileged | baseline | restricted.",
"`Forbidden: User ... cannot list` is authorization. `violates PodSecurity`, `exceeded quota` and `maximum memory usage` are admission.",
],
"fast": """
k create role ci --verb=get,list,watch --resource=pods,pods/log -n NS
k create role ci --verb=get,update,patch --resource=deployments.apps,deployments.apps/scale -n NS
k create rolebinding ci --role=ci --serviceaccount=NS:SA -n NS # --user=jane --clusterrole=view
k auth can-i list pods -n NS --as=system:serviceaccount:NS:SA
k auth can-i --list -n NS --as=system:serviceaccount:NS:SA
k get ns NS --show-labels
# what `restricted` wants from every container:
securityContext: {allowPrivilegeEscalation: false, runAsNonRoot: true, capabilities: {drop: [ALL]},
seccompProfile: {type: RuntimeDefault}}
""",
"traps": [
"The same ServiceAccount is `--serviceaccount=NS:NAME` in a binding and `system:serviceaccount:NS:NAME` after `--as`.",
"`can-i create pods/exec` asks about a Pod named exec; use `can-i create pods --subresource=exec`.",
"Admission rejects Pods, not Deployments: the Deployment exists and shows 0 Pods, and the reason is in its ReplicaSet's events.",
],
"docs": ["https://kubernetes.io/docs/reference/access-authn-authz/rbac/",
"https://kubernetes.io/docs/concepts/security/pod-security-admission/",
"https://kubernetes.io/docs/concepts/security/pod-security-standards/"],
},
{
"id": "4.3", "key": "quotas", "domain": "ENV",
"text": "Understand requests, limits, quotas",
"tasks": [30, 26, 53],
"know": [
"Requests are what the scheduler reserves on a node. Limits are the ceiling: CPU above it is throttled, memory above it gets the container OOMKilled.",
"A ResourceQuota caps a Namespace's totals (requests.cpu, limits.memory, pods, count/...). Once it covers CPU or memory, every new Pod must state them.",
"A LimitRange gives containers a default request and limit and enforces a min and max per container, at admission.",
],
"fast": """
k create quota compute-quota -n NS --hard=requests.cpu=1,requests.memory=1Gi,limits.cpu=2,limits.memory=2Gi,pods=6
k describe quota -n NS # Used against Hard
k describe limitrange -n NS
apiVersion: v1
kind: LimitRange
metadata: {name: container-defaults}
spec:
limits:
- type: Container
defaultRequest: {cpu: 100m, memory: 128Mi} # the request a container gets
default: {cpu: 250m, memory: 256Mi} # the LIMIT a container gets
max: {cpu: 500m, memory: 512Mi}
""",
"traps": [
"`default` is the default limit and `defaultRequest` the default request - they are easy to swap.",
"Quotas and LimitRanges only act on Pods created afterwards.",
"The rejection is not on the Deployment: `k describe rs` shows `exceeded quota` or `maximum memory usage per Container`.",
],
"docs": ["https://kubernetes.io/docs/concepts/policy/resource-quotas/",
"https://kubernetes.io/docs/concepts/policy/limit-range/"],
},
{
"id": "4.4", "key": "configmaps", "domain": "ENV",
"text": "Understand ConfigMaps",
"tasks": [32, 10, 25, 51, 45],
"know": [
"Create from literals, files, directories or env files. Each key becomes an environment variable (configMapKeyRef, envFrom) or a file (a configMap volume; `items` picks keys).",
"Mounted files follow a changed ConfigMap after a short delay. Environment variables and subPath mounts never do - restart the Pods.",
"A Pod that references a missing ConfigMap or key waits in CreateContainerConfigError unless the reference is `optional: true`.",
],
"fast": """
k create cm app --from-literal=TIER=backend --from-file=app.properties # --from-file=KEY=path --from-env-file=x.env
env:
- {name: APP_TIER, valueFrom: {configMapKeyRef: {name: app, key: TIER}}}
envFrom: [{configMapRef: {name: app}}]
volumes: [{name: cfg, configMap: {name: app, items: [{key: app.properties, path: app.properties}]}}]
volumeMounts: [{name: cfg, mountPath: /etc/app}] # one file into a full directory: add subPath: app.properties
k set env deploy/web --from=configmap/app; k exec POD -- env | grep TIER
k rollout restart deploy/web # pick up a change
""",
"traps": [
"`--from-file` makes one key named after the file; `--from-env-file` makes one key per KEY=VALUE line.",
"A volume mounted on a directory hides what the image had there.",
"`immutable: true` cannot be edited or undone - delete the ConfigMap and create it again.",
],
"docs": ["https://kubernetes.io/docs/concepts/configuration/configmap/",
"https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/"],
},
{
"id": "4.5", "key": "resources", "domain": "ENV",
"text": "Define resource requirements",
"tasks": [31, 26, 15, 53],
"know": [
"Per container: resources.requests and resources.limits for cpu (cores or millicores: 250m) and memory (Mi, Gi).",
"QoS class: Guaranteed (every container has requests equal to limits for both CPU and memory), Burstable (some are set), BestEffort (none). BestEffort is evicted first.",
"A container with a limit but no request gets a request equal to the limit.",
],
"fast": """
k set resources deploy/web -c app --requests=cpu=250m,memory=128Mi --limits=cpu=250m,memory=128Mi
resources:
requests: {cpu: 100m, memory: 64Mi}
limits: {cpu: 250m, memory: 128Mi}
k get pod POD -o jsonpath='{.status.qosClass}'
k describe node NODE | grep -A8 'Allocated resources'
""",
"traps": [
"`kubectl run` has no --requests or --limits any more: generate the YAML, or use `kubectl set resources` on a workload.",
"Guaranteed needs every container of the Pod, sidecars included.",
"Memory is Mi, not m: `128m` is a fraction of a byte.",
"A request no node can fit leaves the Pod Pending with `Insufficient cpu` or `Insufficient memory`.",
],
"docs": ["https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/",
"https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/"],
},
{
"id": "4.6", "key": "secrets", "domain": "ENV",
"text": "Create & consume Secrets",
"tasks": [33, 10, 37, 50],
"know": [
"A Secret's `data` is base64 - an encoding, not encryption. `stringData` takes plain text when you write one.",
"Types: Opaque (generic), kubernetes.io/tls, kubernetes.io/dockerconfigjson, kubernetes.io/service-account-token.",
"Consumed like a ConfigMap: secretKeyRef, envFrom secretRef, or a secret volume (`secretName`, `defaultMode`, `items`).",
],
"fast": """
k create secret generic api --from-literal=username=svc --from-literal=password='S3cr3t!'
k create secret tls web-tls --cert=tls.crt --key=tls.key
k create secret docker-registry regcred --docker-server=REG --docker-username=U --docker-password=P
k get secret api -o jsonpath='{.data.password}' | base64 -d
env: [{name: API_USER, valueFrom: {secretKeyRef: {name: api, key: username}}}]
volumes: [{name: creds, secret: {secretName: api, defaultMode: 0400}}]
volumeMounts: [{name: creds, mountPath: /etc/creds, readOnly: true}]
""",
"traps": [
"A secret volume names it with `secretName`; a configMap volume uses `name`.",
"Encoding by hand: `echo -n value | base64`, or the newline becomes part of the secret.",
"defaultMode is octal in YAML (0400) and shows as decimal in JSON (256).",
],
"docs": ["https://kubernetes.io/docs/concepts/configuration/secret/",
"https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/"],
},
{
"id": "4.7", "key": "serviceaccounts", "domain": "ENV",
"text": "Understand ServiceAccounts",
"tasks": [34, 28, 10, 49],
"know": [
"Every Pod runs as a ServiceAccount (`default` if you name none); its short-lived token is mounted at /var/run/secrets/kubernetes.io/serviceaccount.",
"Choose it with spec.serviceAccountName. Stop the mount with `automountServiceAccountToken: false` on the ServiceAccount or on the Pod (the Pod wins).",
"Tokens: `kubectl create token` for a short-lived one; a Secret of type kubernetes.io/service-account-token annotated with the account's name for a long-lived one.",
"A ServiceAccount can do nothing until RBAC binds a Role to it.",
],
"fast": """
k create sa ci -n NS
k set serviceaccount deploy/web ci -n NS # = spec.template.spec.serviceAccountName
k patch sa ci -n NS -p '{"automountServiceAccountToken": false}'
k create token ci -n NS --duration=1h
k auth can-i list pods -n NS --as=system:serviceaccount:NS:ci
apiVersion: v1
kind: Secret
metadata: {name: ci-token, annotations: {kubernetes.io/service-account.name: ci}}
type: kubernetes.io/service-account-token
""",
"traps": [
"No token Secret is made for a new ServiceAccount (since 1.24) - create it yourself when a task wants one.",
"serviceAccountName cannot be changed on a running Pod; on a Deployment it starts a rollout.",
"A Pod naming a ServiceAccount that does not exist is never created - see the ReplicaSet's events.",
],
"docs": ["https://kubernetes.io/docs/concepts/security/service-accounts/",
"https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/"],
},
{
"id": "4.8", "key": "security", "domain": "ENV",
"text": "Understand Application Security (SecurityContexts, Capabilities, etc.)",
"tasks": [9, 29, 44, 52],
"know": [
"Pod level (spec.securityContext): runAsUser, runAsGroup, runAsNonRoot, fsGroup, supplementalGroups, seccompProfile - for every container.",
"Container level adds allowPrivilegeEscalation, privileged, readOnlyRootFilesystem and capabilities, and overrides the Pod level.",
"Capabilities are written without the CAP_ prefix: NET_BIND_SERVICE, NET_ADMIN, SYS_TIME. Drop ALL, then add back what the app needs.",
],
"fast": """
spec:
securityContext: {runAsUser: 1000, runAsGroup: 1000, runAsNonRoot: true, fsGroup: 2000}
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL], add: [NET_BIND_SERVICE]}
k exec POD -- id
k explain pod.spec.securityContext; k explain pod.spec.containers.securityContext
""",
"traps": [
"capabilities exist only on a container, fsGroup only on the Pod.",
"runAsNonRoot with an image that runs as root and no runAsUser: CreateContainerConfigError.",
"readOnlyRootFilesystem breaks an app that writes to /tmp or /var/cache - mount an emptyDir there.",
],
"docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/security-context/"],
},
# -------------------------------------------------- 5. Services and Networking (20%) ----
{
"id": "5.1", "key": "netpol", "domain": "NET",
"text": "Demonstrate basic understanding of NetworkPolicies",
"tasks": [12, 35, 55],
"know": [
"Pods accept and send everything until a NetworkPolicy selects them. From then on, for the policyTypes it lists, only what some policy allows gets through.",
"Policies only allow, and they add up: there is no deny rule and no ordering.",
"Peers: podSelector (same Namespace), namespaceSelector, ipBlock. Two selectors in one list item mean AND; two list items mean OR.",
"Default deny: `podSelector: {}` with a policyType and no rules. Locking down egress also needs DNS allowed (UDP and TCP 53).",
],
"fast": """
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-allow, namespace: NS}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from:
- podSelector: {matchLabels: {app: api}} # OR
- namespaceSelector: {matchLabels: {purpose: monitoring}}
ports: [{protocol: TCP, port: 5432}]
# egress that keeps DNS: egress: [{ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]}, ...]
k describe netpol db-allow -n NS
k exec CLIENT -- wget -qO- -T2 http://db:5432 # test it with real traffic
""",
"traps": [
"There is no `kubectl create networkpolicy` - copy the skeleton from the docs.",
"`- podSelector: ...` followed by `namespaceSelector:` in the same item is AND; a second `- namespaceSelector:` is OR.",
"A missing or empty `ingress` denies everything; `ingress: [{}]` allows everything.",
"The policy lives in the Namespace of the Pods it selects, and a Namespace is matched by its labels (kubernetes.io/metadata.name=NAME always exists).",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/network-policies/"],
},
{
"id": "5.2", "key": "services", "domain": "NET",
"text": "Provide and troubleshoot access to applications via services",
"tasks": [11, 36, 54, 56],
"know": [
"A Service selects Pods by label and forwards `port` to `targetPort` (a number, or the name of a container port).",
"Types: ClusterIP (inside the cluster), NodePort (30000-32767 on every node), LoadBalancer, ExternalName; headless (`clusterIP: None`) gives each Pod its own DNS record.",
"DNS names: SVC, SVC.NS, SVC.NS.svc.cluster.local.",
"Troubleshoot in this order: are there endpoints, does the selector match the Pod labels, are the Pods Ready, is targetPort the port the container listens on.",
],
"fast": """
k expose deploy web --port=8080 --target-port=80 # --name=web-svc --type=NodePort
k create svc nodeport web-ext --tcp=80:80 --node-port=30080
k get svc,endpointslices -o wide; k describe svc web # Endpoints: <none> = nothing selected
k get pods -l app=web --show-labels
k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://web:8080
k port-forward svc/web 8080:80
""",
"traps": [
"`kubectl expose` copies the workload's selector; `kubectl create svc` selects app=<service name>, which may match nothing.",
"`expose` cannot choose the nodePort - use `create svc nodeport --node-port` or edit the YAML.",
"A Service without endpoints still resolves in DNS; the connection is what fails.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/service/",
"https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/",
"https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/"],
},
{
"id": "5.3", "key": "ingress", "domain": "NET",
"text": "Use Ingress rules to expose applications",
"tasks": [37, 11, 57],
"know": [
"An Ingress routes HTTP(S) by host and path to Services of its own Namespace; an ingress controller, chosen with ingressClassName, does the work.",
"Each path has a pathType (Prefix, Exact or ImplementationSpecific) and a backend: service.name plus service.port.number or .name.",
"TLS: spec.tls lists hosts and the kubernetes.io/tls Secret that holds their certificate.",
],
"fast": """
k create ingress shop --class=nginx \\
--rule="shop.example.com/api*=api:8080,tls=shop-tls" --rule="shop.example.com/*=web:80,tls=shop-tls"
k create secret tls shop-tls --cert=tls.crt --key=tls.key
k get ingress,ingressclass; k describe ingress shop # a missing backend shows as <error: services "x" not found>
k explain ingress.spec.rules.http.paths
""",
"traps": [
"In --rule a trailing * means pathType Prefix; without it the path is Exact.",
"The backend port is the Service's port, not the container's.",
"No ingress controller runs in this simulator, so Ingress tasks are graded on the object - on the exam, curl it.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/ingress/"],
},
]

View File

@ -1,6 +1,5 @@
"""CKAD track: 44 pool tasks, at least two per objective of the CKAD curriculum (1-12 here, 13-37 """CKAD track: 37 pool tasks, at least one per competency in the CKAD curriculum (1-12 here, 13-37
in bank_extra.py, 58-64 in bank_more.py), plus Simulator Set A (38-57, bank_sim_a.py). Same format in bank_extra.py), plus Simulator Set A (38-57, bank_sim_a.py). Same format as the CKA banks.
as the CKA banks. curriculum.py maps every task to the objectives it practises (./exam objectives).
CKAD is a build-things exam: less cluster surgery, more "produce the right object, fast". CKAD is a build-things exam: less cluster surgery, more "produce the right object, fast".
The clock is the adversary, so these are mostly single-object tasks graded exactingly. The clock is the adversary, so these are mostly single-object tasks graded exactingly.
@ -26,7 +25,7 @@ TRACK = {
"NET": "Services and Networking (20%)", "NET": "Services and Networking (20%)",
}, },
# 16 tasks in 120 minutes, weighted like the exam (20/20/15/25/20 %). The pool per domain is # 16 tasks in 120 minutes, weighted like the exam (20/20/15/25/20 %). The pool per domain is
# 9/8/11/11/5, so no two mocks are the same set. # 8/7/7/10/5, so no two mocks are the same set.
"quota": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3}, "quota": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3},
"quota_hard": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3}, "quota_hard": {"DESIGN": 3, "DEPLOY": 3, "OBS": 3, "ENV": 4, "NET": 3},
# fixed simulator sets (./exam sim a) - excluded from random sampling # fixed simulator sets (./exam sim a) - excluded from random sampling
@ -589,7 +588,6 @@ EOF
# 13-37: the rest of the curriculum, one or more tasks per competency the starter bank left out. # 13-37: the rest of the curriculum, one or more tasks per competency the starter bank left out.
# 38-57: Simulator Set A, a fixed killer.sh-style mock. Numbering is contiguous - keep it that way. # 38-57: Simulator Set A, a fixed killer.sh-style mock. Numbering is contiguous - keep it that way.
# 58-64: a second pool task for each objective that had one (see curriculum.py). from tracks.ckad import bank_extra, bank_sim_a # noqa: E402
from tracks.ckad import bank_extra, bank_more, bank_sim_a # noqa: E402
QUESTIONS = QUESTIONS + bank_extra.QUESTIONS + bank_sim_a.QUESTIONS + bank_more.QUESTIONS QUESTIONS = QUESTIONS + bank_extra.QUESTIONS + bank_sim_a.QUESTIONS

View File

@ -129,29 +129,6 @@
.pill.pick:hover { border-color:var(--accent); color:var(--accent-hover); } .pill.pick:hover { border-color:var(--accent); color:var(--accent-hover); }
.pill.done.pick:hover { color:var(--ok); } .pill.done.pick:hover { color:var(--ok); }
.pill.done { background:var(--ok-soft); border-color:var(--ok); color:var(--ok); } .pill.done { background:var(--ok-soft); border-color:var(--ok); color:var(--ok); }
/* ---- curriculum: objective list in the lobby, notes beside a task ---- */
.notes h4 { margin: 12px 0 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; }
.notes ul { margin: 0; padding-left: 18px; } .notes li { margin: 3px 0; }
.notes code { background: var(--pane-2); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--line); color: #c9d4f2; }
.notes pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.55 var(--mono); background: var(--bg-alt); border: 1px solid var(--line); padding: 10px 12px; border-radius: 6px; color: #c9d4f2; }
.notes a { overflow-wrap: anywhere; }
#objNotes { margin-top: 14px; border: 1px solid var(--line); border-radius: 6px; padding: 10px 14px; font-size: 13px; background: var(--pane-2); }
#objNotes summary { cursor: pointer; font-weight: 600; }
#objList { max-height: 56vh; overflow-y: auto; margin-top: 8px; padding-right: 4px; }
#objList h3 { margin: 14px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 600; }
details.obj { border-top: 1px solid var(--line); padding: 7px 0; font-size: 13px; }
details.obj > summary { cursor: pointer; display: grid; grid-template-columns: 16px 30px 1fr auto; gap: 8px; align-items: baseline; list-style: none; }
details.obj > summary::-webkit-details-marker { display: none; }
details.obj > summary:hover .otext { color: var(--accent-hover); }
details.obj[open] > summary { margin-bottom: 6px; }
details.obj .oid { font-family: var(--mono); color: var(--muted); }
.mk { font-family: var(--mono); font-weight: 600; text-align: center; }
.st-done, .st-passed { color: var(--ok); } .st-studied, .st-started { color: var(--warn); } .st-failed { color: var(--danger); } .st-new { color: var(--muted); }
.chips { font: 12px var(--mono); white-space: nowrap; color: var(--muted); }
.chips span { margin-left: 6px; }
.ladder { display: grid; grid-template-columns: auto 1fr auto; gap: 2px 12px; font-size: 13px; margin: 0 0 14px; padding: 10px 12px;
background: var(--pane-2); border: 1px solid var(--line); border-radius: 8px; }
.ladder .here { color: var(--accent-hover); font-weight: 600; } .ladder .muted { color: var(--muted); }
.hardtag { font-size:11px; background:var(--danger); color:#fff; border-radius:3px; padding:1px 5px; margin-left:6px; vertical-align:middle; } .hardtag { font-size:11px; background:var(--danger); color:#fff; border-radius:3px; padding:1px 5px; margin-left:6px; vertical-align:middle; }
@media (prefers-reduced-motion: no-preference) { .prose code { transition: background .15s, border-color .15s; } } @media (prefers-reduced-motion: no-preference) { .prose code { transition: background .15s, border-color .15s; } }
@media (max-width: 900px) { main { grid-template-columns: 56px 1fr; } #handle, #termwrap { display: none; } } @media (max-width: 900px) { main { grid-template-columns: 56px 1fr; } #handle, #termwrap { display: none; } }
@ -179,7 +156,6 @@
<div class="prose" id="qBody"></div> <div class="prose" id="qBody"></div>
<div id="checks" class="hidden"></div> <div id="checks" class="hidden"></div>
<details id="solution" class="hidden"><summary>Reference solution</summary><pre id="solutionBody"></pre></details> <details id="solution" class="hidden"><summary>Reference solution</summary><pre id="solutionBody"></pre></details>
<details id="objNotes" class="hidden"><summary id="objNotesSum"></summary><div class="notes" id="objNotesBody"></div></details>
</div> </div>
<div class="foot"> <div class="foot">
<button class="btn" id="btnPrev">Previous</button> <button class="btn" id="btnPrev">Previous</button>
@ -205,9 +181,7 @@
<div id="coverage" style="display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap"></div> <div id="coverage" style="display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap"></div>
<label style="display:block;margin-bottom:14px"><input type="checkbox" id="hardMode"> <b>Hard mode</b> <label style="display:block;margin-bottom:14px"><input type="checkbox" id="hardMode"> <b>Hard mode</b>
<span id="hardDesc" style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks.</span></label> <span id="hardDesc" style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks.</span></label>
<div class="ladder hidden" id="ladder"></div>
<div class="opts" id="lobbyOpts"> <div class="opts" id="lobbyOpts">
<button class="opt hidden" id="optPath" style="grid-column:1/3;border-color:var(--accent)"><b id="pathTitle">Continue the study path</b><span id="pathDesc"></span></button>
<button class="opt" id="optStudy"><b>Study mode</b><span>5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.</span></button> <button class="opt" id="optStudy"><b>Study mode</b><span>5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.</span></button>
<button class="opt" id="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button> <button class="opt" id="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button>
<button class="opt" id="optAll"><b>Drill the whole bank</b><span id="allDesc"></span></button> <button class="opt" id="optAll"><b>Drill the whole bank</b><span id="allDesc"></span></button>
@ -221,11 +195,6 @@
<div class="picker" id="picker"></div> <div class="picker" id="picker"></div>
<div class="row"><button class="btn primary" id="btnStartDrill">Start drill</button><button class="btn" id="btnBackDrill">Back</button></div> <div class="row"><button class="btn primary" id="btnStartDrill">Start drill</button><button class="btn" id="btnBackDrill">Back</button></div>
</div> </div>
<div id="objPick" class="hidden">
<div class="sub" style="margin:14px 0 0" id="objSub"></div>
<div id="objList"></div>
<div class="row"><button class="btn" id="btnBackObj">Back</button></div>
</div>
<div id="seeding" class="hidden"> <div id="seeding" class="hidden">
<div class="sub">Seeding scenarios on the cluster…</div> <div class="sub">Seeding scenarios on the cluster…</div>
<pre class="log" id="seedLog"></pre> <pre class="log" id="seedLog"></pre>
@ -251,7 +220,6 @@
<div class="sub" id="resSub"></div> <div class="sub" id="resSub"></div>
<div><span class="score" id="resScore"></span><span class="verdict" id="resVerdict"></span></div> <div><span class="score" id="resScore"></span><span class="verdict" id="resVerdict"></span></div>
<div id="resDomains" style="margin-top:14px"></div> <div id="resDomains" style="margin-top:14px"></div>
<div id="resPath" style="margin-top:12px;font-size:13px"></div>
<div class="qres" id="resQuestions"></div> <div class="qres" id="resQuestions"></div>
<div class="row"> <div class="row">
<button class="btn primary" id="btnDrillMissed">Drill what I missed</button> <button class="btn primary" id="btnDrillMissed">Drill what I missed</button>
@ -286,55 +254,6 @@
.replace(/`([^`]+)`/g, (m, c) => `<code title="click to copy">${c}</code>`) + '</p>').join(''); .replace(/`([^`]+)`/g, (m, c) => `<code title="click to copy">${c}</code>`) + '</p>').join('');
} }
// ---- curriculum ----
const MARK = {done: '✓', passed: '✓', studied: '~', started: '-', failed: '✗', new: '·'};
const mk = st => `<span class="mk st-${st}">${MARK[st] || '·'}</span>`;
const inline = s => esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
function notesHtml(o) {
return `<h4>Know</h4><ul>${o.know.map(x => `<li>${inline(x)}</li>`).join('')}</ul>` +
`<h4>Fast</h4><pre>${esc(o.fast)}</pre>` +
`<h4>Traps</h4><ul>${o.traps.map(x => `<li>${inline(x)}</li>`).join('')}</ul>` +
`<h4>Docs you may open in the exam</h4><ul>${o.docs.map(u => `<li><a href="${esc(u)}" target="_blank" rel="noopener">${esc(u.replace('https://', ''))}</a></li>`).join('')}</ul>`;
}
function continuePath() {
const n = S.path && S.path.next; if (!n) return;
if (n.kind === 'study') start('drill', null, false, false, null, true, n.objective);
else if (n.kind === 'drill') start('drill', null, false, false, null, false, n.objective);
else if (n.kind === 'sim') startSim(n.set);
else start('exam');
}
function paintPath() {
const P = S.path, has = !!P;
$('#optPath').classList.toggle('hidden', !has); $('#ladder').classList.toggle('hidden', !has);
$('#optStudy').querySelector('b').textContent = has ? 'Study an objective' : 'Study mode';
$('#optStudy').querySelector('span').textContent = has
? `Pick any of the ${P.total} objectives: its notes, then its tasks with no clock and the solutions visible.`
: '5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.';
if (!has) return;
const best = v => v == null ? 'not sat yet' : `best ${v}%`, k = P.next.kind;
const rows = [['study', 'Study', `${P.studied}/${P.total} objectives`], ['drill', 'Against the clock', `${P.done}/${P.total} objectives`],
['mock', 'Mock exam', best(P.mock_best)]].concat(Object.keys(S.sets || {}).map(x => ['sim:' + x, S.sets[x].name, best(P.set_best[x])]));
$('#ladder').innerHTML = rows.map(([kind, name, where], i) => {
const here = kind === k || kind === 'sim:' + P.next.set && k === 'sim';
return `<span class="${here ? 'here' : 'muted'}">${here ? '→' : '&nbsp;'} ${i + 1}</span><span class="${here ? 'here' : ''}">${esc(name)}</span><span class="muted">${esc(where)}</span>`;
}).join('');
$('#pathTitle').textContent = 'Continue the path · ' + P.next.cmd.replace('./exam ', '');
$('#pathDesc').textContent = P.next.why + '.';
const chip = t => `<span class="st-${t.state}" title="Q${t.id} ${esc(t.title)}${t.set ? ' (simulator set)' : ''}">${MARK[t.state]}${t.id}</span>`;
// pool tasks, then the simulator-set ones in one pair of brackets (they stay out of objective sessions)
const chips = o => o.tasks.filter(t => !t.set).map(chip).join('') +
(o.tasks.some(t => t.set) ? `<span>[</span>${o.tasks.filter(t => t.set).map(chip).join('').replace('<span', '<span style="margin-left:0"')}<span style="margin-left:0">]</span>` : '');
const doms = [...new Set(S.objectives.map(o => o.domain))];
$('#objSub').textContent = '✓ complete: every task passed against the clock. ~ studied. - started. Open an objective for its notes.';
$('#objList').innerHTML = doms.map(d => `<h3>${esc(S.track.domains[d])}</h3>` + S.objectives.filter(o => o.domain === d).map(o =>
`<details class="obj"><summary>${mk(o.state)}<span class="oid">${o.id}</span><span class="otext">${esc(o.text)}</span><span class="chips">${chips(o)}</span></summary>
<div class="notes">${notesHtml(o)}</div>
<div class="row" style="margin:10px 0 6px"><button class="btn primary" data-study="${o.id}">Study it · no clock</button><button class="btn" data-drill="${o.id}">Against the clock</button></div>
</details>`).join('')).join('');
$('#objList').querySelectorAll('[data-study]').forEach(b => b.onclick = () => start('drill', null, false, false, null, true, b.dataset.study));
$('#objList').querySelectorAll('[data-drill]').forEach(b => b.onclick = () => start('drill', null, false, false, null, false, b.dataset.drill));
}
function qResultClass(q) { function qResultClass(q) {
const r = S.session && S.session.results[q.id]; const r = S.session && S.session.results[q.id];
if (!r) return ''; if (!r) return '';
@ -344,8 +263,7 @@
function paint() { function paint() {
const sess = S.session, live = sess && !sess.ended_at; const sess = S.session, live = sess && !sess.ended_at;
$('#trackName').textContent = `${S.track.name} · Kubernetes ${S.track.k8s}`; $('#trackName').textContent = `${S.track.name} · Kubernetes ${S.track.k8s}`;
$('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') $('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') : '';
+ (sess.objective ? ' · objective ' + sess.objective : '') : '';
$('#btnEnd').classList.toggle('hidden', !live); $('#btnEnd').classList.toggle('hidden', !live);
if (live && sess.untimed) $('#btnEnd').textContent = 'End study session'; if (live && sess.untimed) $('#btnEnd').textContent = 'End study session';
$('#btnPause').classList.toggle('hidden', !live || sess.untimed); $('#btnPause').classList.toggle('hidden', !live || sess.untimed);
@ -378,11 +296,6 @@
$('#checks').classList.toggle('hidden', !show); $('#checks').classList.toggle('hidden', !show);
$('#solution').classList.toggle('hidden', !q.solution); $('#solution').classList.toggle('hidden', !q.solution);
if (q.solution) $('#solutionBody').textContent = q.solution; if (q.solution) $('#solutionBody').textContent = q.solution;
// the objective this task practises: the session's own if the task belongs to it, else its first
const oids = q.objectives || [], oid = oids.includes(sess && sess.objective) ? sess.objective : oids[0];
const obj = oid && (S.objectives || []).find(o => o.id === oid);
$('#objNotes').classList.toggle('hidden', !obj);
if (obj) { $('#objNotesSum').textContent = `Objective ${obj.id} · ${obj.text}`; $('#objNotesBody').innerHTML = notesHtml(obj); }
if (show) $('#checks').innerHTML = `<b>${r.earned}/${r.total} points</b>` + if (show) $('#checks').innerHTML = `<b>${r.earned}/${r.total} points</b>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}">${esc(c.name)} <span style="color:var(--muted)">(${c.pts})</span></div>`).join(''); r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}">${esc(c.name)} <span style="color:var(--muted)">(${c.pts})</span></div>`).join('');
const i = S.questions.indexOf(q); const i = S.questions.indexOf(q);
@ -444,7 +357,6 @@
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1; $('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); }; const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
$('#lobbyTitle').textContent = S.track.name; $('#lobbyTitle').textContent = S.track.name;
paintPath();
// only the track that owns the k8s-c* contexts (CKA) switches between them // only the track that owns the k8s-c* contexts (CKA) switches between them
$('#hardDesc').textContent = ' — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks' $('#hardDesc').textContent = ' — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks'
+ (S.track.exam_contexts ? ', exam contexts.' : '.'); + (S.track.exam_contexts ? ', exam contexts.' : '.');
@ -479,12 +391,11 @@
} finally { $('#seeding').classList.add('hidden'); } } finally { $('#seeding').classList.add('hidden'); }
} }
async function start(mode, ids, next, campaign, domain, study, objective) { async function start(mode, ids, next, campaign, domain, study) {
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#objPick').classList.add('hidden'); $('#lobbyErr').classList.add('hidden'); $('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…'; $('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try { try {
await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, study: !!study, await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, study: !!study, hard: $('#hardMode').checked});
objective: objective || null, hard: $('#hardMode').checked});
// seeding runs in the background; stream its log until it finishes // seeding runs in the background; stream its log until it finishes
for (;;) { for (;;) {
await new Promise(r => setTimeout(r, 1200)); await new Promise(r => setTimeout(r, 1200));
@ -516,7 +427,7 @@
function showResults() { function showResults() {
const s = S.session, sc = s.score; const s = S.session, sc = s.score;
$('#results').dataset.shown = 1; $('#results').dataset.shown = 1;
$('#resSub').textContent = `${s.untimed ? 'Study session' : (s.mode === 'drill' ? 'Drill' : 'Full exam')} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}`; $('#resSub').textContent = `${s.mode === 'drill' ? 'Drill' : 'Full exam'} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}`;
$('#resScore').innerHTML = `${sc.pct}% <small>${sc.earned} / ${sc.total} points</small>`; $('#resScore').innerHTML = `${sc.pct}% <small>${sc.earned} / ${sc.total} points</small>`;
const v = $('#resVerdict'); v.textContent = sc.pct >= sc.pass_pct ? 'Pass' : 'Fail'; v.className = 'verdict ' + (sc.pct >= sc.pass_pct ? 'pass' : 'fail'); const v = $('#resVerdict'); v.textContent = sc.pct >= sc.pass_pct ? 'Pass' : 'Fail'; v.className = 'verdict ' + (sc.pct >= sc.pass_pct ? 'pass' : 'fail');
$('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) => $('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) =>
@ -526,12 +437,9 @@
return `<details class="${cls}"><summary>Q${q.id} ${esc(q.title)}<span class="pts">${r.earned}/${r.total}</span></summary>` + return `<details class="${cls}"><summary>Q${q.id} ${esc(q.title)}<span class="pts">${r.earned}/${r.total}</span></summary>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}" style="padding-left:14px">${c.pass ? '✓' : '✗'} ${esc(c.name)} (${c.pts})</div>`).join('') + r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}" style="padding-left:14px">${c.pass ? '✓' : '✗'} ${esc(c.name)} (${c.pts})</div>`).join('') +
(q.solution ? `<details style="margin:6px 0 4px 14px"><summary style="font-size:13px">Reference solution</summary><pre style="white-space:pre-wrap;font:12px/1.5 var(--mono);background:var(--bg-alt);border:1px solid var(--line);color:#c9d4f2;padding:8px;border-radius:6px">${esc(q.solution)}</pre></details>` : '') + '</details>'; }).join(''); (q.solution ? `<details style="margin:6px 0 4px 14px"><summary style="font-size:13px">Reference solution</summary><pre style="white-space:pre-wrap;font:12px/1.5 var(--mono);background:var(--bg-alt);border:1px solid var(--line);color:#c9d4f2;padding:8px;border-radius:6px">${esc(q.solution)}</pre></details>` : '') + '</details>'; }).join('');
$('#resPath').innerHTML = !S.path ? '' : (s.milestones || []).map(m => `<div class="st-done">${esc(m)}</div>`).join('') +
`<div style="margin-top:6px"><b>Next on the path:</b> <code>${esc(S.path.next.cmd)}</code> <span style="color:var(--muted)">${esc(S.path.next.why)}</span></div>`;
const missed = S.questions.filter(q => s.results[q.id] && !s.results[q.id].pass).map(q => q.id); const missed = S.questions.filter(q => s.results[q.id] && !s.results[q.id].pass).map(q => q.id);
$('#btnDrillMissed').disabled = !missed.length; $('#btnDrillMissed').onclick = () => { $('#results').classList.add('hidden'); start('drill', missed); }; $('#btnDrillMissed').disabled = !missed.length; $('#btnDrillMissed').onclick = () => { $('#results').classList.add('hidden'); start('drill', missed); };
$('#results').classList.remove('hidden'); $('#lobby').classList.add('hidden'); $('#results').classList.remove('hidden'); $('#lobby').classList.add('hidden');
paintPath(); // the lobby behind the results now shows where this session left the path
} }
async function showHistory() { async function showHistory() {
@ -546,12 +454,7 @@
// ---- wiring ---- // ---- wiring ----
$('#optExam').onclick = () => start('exam'); $('#optExam').onclick = () => start('exam');
$('#optNext').onclick = () => start('drill', null, true); $('#optNext').onclick = () => start('drill', null, true);
$('#optStudy').onclick = () => { $('#optStudy').onclick = () => start('drill', null, true, false, null, true);
if (!S.path) return start('drill', null, true, false, null, true);
$('#lobbyOpts').classList.add('hidden'); $('#gate').classList.add('hidden'); $('#objPick').classList.remove('hidden');
};
$('#optPath').onclick = continuePath;
$('#btnBackObj').onclick = () => { $('#objPick').classList.add('hidden'); $('#lobbyOpts').classList.remove('hidden'); load(); };
$('#optAll').onclick = () => start('drill', null, false, true); $('#optAll').onclick = () => start('drill', null, false, true);
$('#hardMode').onchange = () => load(); $('#hardMode').onchange = () => load();
$('#optDrill').onclick = async () => { $('#optDrill').onclick = async () => {