docs: modified ckad track and all necessary tools

This commit is contained in:
vumast3r 2026-10-05 14:58:56 -06:00
parent d3788be5e2
commit 5e3d593f79
22 changed files with 591 additions and 159 deletions

View File

@ -2,6 +2,8 @@
This guide is designed for fast mental retrieval during exam scenarios. It outlines **which command to choose based on the situation**, the exact syntax to run, and the official documentation shortcuts permitted during the exam. This guide is designed for fast mental retrieval during exam scenarios. It outlines **which command to choose based on the situation**, the exact syntax to run, and the official documentation shortcuts permitted during the exam.
The CKA environment runs **Kubernetes v1.35** (etcd 3.6) as of October 2026, and so does this simulator's CKA track.
--- ---
## 1. Quick Environment Setup & Muscle Memory ## 1. Quick Environment Setup & Muscle Memory
@ -52,7 +54,7 @@ EOF
| **Control Plane Troubleshooting** | Inspect `/etc/kubernetes/manifests/` | Check `kube-apiserver.yaml`, `kube-scheduler.yaml`, `kube-controller-manager.yaml` | | **Control Plane Troubleshooting** | Inspect `/etc/kubernetes/manifests/` | Check `kube-apiserver.yaml`, `kube-scheduler.yaml`, `kube-controller-manager.yaml` |
| **Etcd Backup** | `etcdctl snapshot save <file>` | `--endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=...` | | **Etcd Backup** | `etcdctl snapshot save <file>` | `--endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=...` |
| **Etcd Restore** | `etcdutl snapshot restore <file>` | `--data-dir=/var/lib/etcd-backup` (update `etcd.yaml` volume hostPath). `etcdctl snapshot restore` is gone from etcd 3.6+ | | **Etcd Restore** | `etcdutl snapshot restore <file>` | `--data-dir=/var/lib/etcd-backup` (update `etcd.yaml` volume hostPath). `etcdctl snapshot restore` is gone from etcd 3.6+ |
| **Upgrade Node (Control Plane / Worker)** | `apt-get update && apt-get install -y --allow-change-held-packages kubeadm=1.31.x-1.1` | `kubeadm upgrade plan` -> `kubeadm upgrade apply` -> upgrade `kubelet`/`kubectl` -> `systemctl restart kubelet` | | **Upgrade Node (Control Plane / Worker)** | `apt-get update && apt-get install -y --allow-change-held-packages kubeadm='1.36.5-*'` | pkgs.k8s.io has one repo per minor: point `/etc/apt/sources.list.d/kubernetes.list` at `.../core:/stable:/v1.36/deb/` first. Then `kubeadm upgrade plan` -> `kubeadm upgrade apply v1.36.5` (workers: `kubeadm upgrade node`) -> upgrade `kubelet`/`kubectl` -> `systemctl restart kubelet` |
--- ---
@ -105,16 +107,17 @@ Then edit `/etc/kubernetes/manifests/etcd.yaml` to update the hostPath volume fo
Restore is an offline file operation, so it needs no endpoints or certificates - and it lives in Restore is an offline file operation, so it needs no endpoints or certificates - and it lives in
`etcdutl`, not `etcdctl`: `etcdctl snapshot restore` was deprecated in etcd 3.5 and is gone from `etcdutl`, not `etcdctl`: `etcdctl snapshot restore` was deprecated in etcd 3.5 and is gone from
3.6+ (this cluster runs 3.7, where it fails with `unknown flag: --data-dir`). Check first with 3.6+ (the CKA track's v1.35 cluster runs 3.6, like the exam; there it fails with `unknown flag: --data-dir`). Check first with
`etcdctl version`; on a 3.5 node the old `ETCDCTL_API=3 etcdctl snapshot restore` still works. `etcdctl version`; on a 3.5 node the old `ETCDCTL_API=3 etcdctl snapshot restore` still works.
--- ---
## 5. Official Kubernetes Documentation URLs Allowed in CKA ## 5. Official Kubernetes Documentation URLs Allowed in CKA
- Kubernetes Documentation: `https://kubernetes.io/docs/` Per the LF "Resources Allowed" page (checked 2026-10-02):
- Kubernetes Documentation: `https://kubernetes.io/docs/` (the kubectl cheatsheet lives here: `https://kubernetes.io/docs/reference/kubectl/cheatsheet/`)
- Kubernetes Blog: `https://kubernetes.io/blog/` - Kubernetes Blog: `https://kubernetes.io/blog/`
- GitHub Kubernetes Organization: `https://github.com/kubernetes/` - Helm Documentation: `https://helm.sh/docs`
- Cheatsheet: `https://kubernetes.io/docs/reference/kubectl/cheatsheet/` - Gateway API Documentation (CKA only): `https://gateway-api.sigs.k8s.io`
### Pro Tip for Search in Docs: ### Pro Tip for Search in Docs:
Use the documentation search bar for exact resource types, e.g., `NetworkPolicy`, `PersistentVolume`, `Ingress`, or `kubeadm upgrade`. Copy YAML snippets directly from the docs and edit with `$do`. Use the documentation search bar for exact resource types, e.g., `NetworkPolicy`, `PersistentVolume`, `Ingress`, or `kubeadm upgrade`. Copy YAML snippets directly from the docs and edit with `$do`.

View File

@ -2,6 +2,8 @@
This guide explains how to approach and solve every question in the CKA exam bank. Every entry gives you the **underlying Kubernetes concept** (why the mechanism behaves the way it does), the **exact commands/manifests** that solve the task, and the **gotchas the grader is actually checking for** — the small details that separate a passing answer from a plausible-looking one. `k` is used throughout as the `kubectl` alias (already configured on the exam VMs). This guide explains how to approach and solve every question in the CKA exam bank. Every entry gives you the **underlying Kubernetes concept** (why the mechanism behaves the way it does), the **exact commands/manifests** that solve the task, and the **gotchas the grader is actually checking for** — the small details that separate a passing answer from a plausible-looking one. `k` is used throughout as the `kubectl` alias (already configured on the exam VMs).
The CKA track runs on **Kubernetes v1.35** (kind cluster `cka-sim-135`, etcd 3.6, kubectl 1.35), the version the real CKA environment runs as of October 2026 — so `etcdutl`, not `etcdctl`, restores and inspects snapshots, and output matches what the exam's kubectl prints.
## Practicing the same ground on Killercoda ## Practicing the same ground on Killercoda
This bank mirrors the real CKA curriculum weights (25/15/20/10/30% across the five domains), and so does Killercoda's free, browser-based CKA scenario set. Killercoda groups its own scenarios into five domains too — the mapping below cross-references its actual scenario titles against the equivalent questions in this bank, so you know exactly which free scenario to run when a topic here feels shaky. This bank mirrors the real CKA curriculum weights (25/15/20/10/30% across the five domains), and so does Killercoda's free, browser-based CKA scenario set. Killercoda groups its own scenarios into five domains too — the mapping below cross-references its actual scenario titles against the equivalent questions in this bank, so you know exactly which free scenario to run when a topic here feels shaky.
@ -304,7 +306,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
k get node %{node} -o jsonpath='{.status.nodeInfo.containerRuntimeVersion}' # runtime k get node %{node} -o jsonpath='{.status.nodeInfo.containerRuntimeVersion}' # runtime
k -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].spec.containers[0].image}' # parse tag after the colon k -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].spec.containers[0].image}' # parse tag after the colon
``` ```
- **Gotchas**: write exactly the value the cluster reports (`gitVersion`, not `major`/`minor` composed by hand) — a hand-typed guess like `v1.30` instead of the exact `v1.30.4` string will fail an exact-match check. - **Gotchas**: write exactly the value the cluster reports (`gitVersion`, not `major`/`minor` composed by hand) — a hand-typed guess like `v1.35` instead of the exact `v1.35.8` string will fail an exact-match check.
--- ---

View File

@ -6,8 +6,9 @@ Hands-on exam tasks graded **per check** against a real cluster, an exam clock,
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** (102 tasks: a random pool of 43 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** (37 pool tasks, at 24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), **CKAD** (37 pool tasks, at
least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (scaffolded — same format, add least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (a
questions when you get there). 9-task starter bank in the same format). Each track runs on the Kubernetes version its real exam
runs — CKA and CKS on **v1.35**, CKAD on **v1.37** (see [Clusters and versions](#clusters-and-versions)).
## How a session is built ## How a session is built
@ -25,8 +26,9 @@ left behind, a relocated `staticPodPath`, two PVs where only one is right), exac
graded byte-for-byte, compound failures (three faults on one node, two control-plane components graded byte-for-byte, compound failures (three faults on one node, two control-plane components
down for different reasons, DNS broken three ways), collateral-damage checks (still cordoned, down for different reasons, DNS broken three ways), collateral-damage checks (still cordoned,
recreated instead of fixed in place, extra PVs), and exam-style contexts `k8s-c1-H` / recreated instead of fixed in place, extra PVs), and exam-style contexts `k8s-c1-H` /
`k8s-c2-AC` / `k8s-c3-CCC` with different default namespaces — each task says which to use. `k8s-c2-AC` / `k8s-c3-CCC` with different default namespaces — each task says which to use
Grading always runs against the real cluster context regardless of which one you're on. (CKA only; they point at the CKA cluster). Grading always runs against the track's own cluster
context regardless of which one you're on.
## Why kind and not k3s / hosted labs ## Why kind and not k3s / hosted labs
@ -38,14 +40,46 @@ of `ssh <node>` here; use the Vagrant/ssh mode below if you want a real ssh sess
## Requirements ## Requirements
podman, kind, kubectl, python3, **ttyd** (for the terminal pane in the browser UI). podman, kind (v0.33.0 publishes both node images used here), kubectl, python3, **ttyd** (for the
~4 GB RAM for the 4-node cluster. Rootless podman needs cgroup v2; otherwise `sudo -E ./setup.sh`. terminal pane in the browser UI). ~4 GB RAM per 4-node cluster — about 8 GB with both up; stop the
one you are not using with `podman stop $(podman ps -q --filter label=io.x-k8s.kind.cluster=cka-sim-137)` (`./setup.sh ckad`
starts it again). Rootless podman needs cgroup v2; otherwise `sudo -E ./setup.sh`.
Docker still works: `CONTAINER_RUNTIME=docker ./setup.sh`. Docker still works: `CONTAINER_RUNTIME=docker ./setup.sh`.
## Clusters and versions
The real exams do not share a Kubernetes version, so neither do the tracks. Each Kubernetes minor
gets its own kind cluster, and every `./exam` command uses the cluster of its track:
| Track | Real exam runs | kind cluster | Context | Node image |
|---|---|---|---|---|
| CKA, CKS | v1.35 | `cka-sim-135` | `kind-cka-sim-135` | `kindest/node:v1.35.8` (etcd 3.6) |
| CKAD | v1.37 | `cka-sim-137` | `kind-cka-sim-137` | `kindest/node:v1.37.0` (etcd 3.7) |
Versions are from the Linux Foundation's exam instructions as of 2026-10-02 (CKAD moved from v1.35
to v1.37 at the end of September; the LF moves each exam on its own schedule). The domains and
weights did not change with it — the v1.37 CKAD curriculum is the v1.35 document. When an exam
moves, change `"k8s"` in its `TRACK` (and add the node image to `KIND_IMAGES` in `./exam` if it is
a new minor), then run `./setup.sh <track>`: tracks on the same minor share a cluster. The old
minor's cluster is then unused — delete it with `KIND_EXPERIMENTAL_PROVIDER=podman kind delete
cluster --name cka-sim-<old>` (plain `./teardown.sh` removes every cka-sim cluster).
`setup.sh` also downloads the kubectl that matches each cluster (`.state/kubectl/v1.35/kubectl`,
...), because the exam's kubectl matches its cluster and kubectl 1.36+ behaves differently in
places (`kubectl describe` with a selector hides Events, `kubectl debug` defaults to the `general`
profile). Every session start points the kubectl context *and* `.state/kubectl/current` at the
track's cluster, and `examrc` puts `current/` first on `PATH`, so your terminals follow. Only one
session can be open at a time across tracks (`--force` overrides on the command line). `./exam
clusters` shows what each track uses and whether it is up.
The single cluster `cka-sim` from before per-version clusters is not used any more:
`./teardown.sh legacy` deletes it.
## Run ## Run
``` ```
./setup.sh # create the kind cluster, preload images (idempotent) ./setup.sh [cka|ckad|cks|all] # create that track's cluster, preload images (idempotent;
# default: the track ./exam defaults to - $CKA_TRACK, else cka)
./exam web # open http://127.0.0.1:8080 — start an exam or drill from there ./exam web # open http://127.0.0.1:8080 — start an exam or drill from there
``` ```
@ -60,8 +94,8 @@ source ./examrc.fish # fish equivalent
That's the whole loop. The UI seeds the scenarios, runs the clock, lets you flag questions and That's the whole loop. The UI seeds the scenarios, runs the clock, lets you flag questions and
keep notes, submits (or auto-submits at 00:00), shows the score with every check, and offers keep notes, submits (or auto-submits at 00:00), shows the score with every check, and offers
"Drill what I missed". The terminal pane is a real `bash` via ttyd with kubectl already pointed "Drill what I missed". The terminal pane is a real `bash` via ttyd with kubectl pointed at the
at the cluster (`--shell fish` if you'd rather practise in fish; the real exam is bash). cluster of the session's track (`--shell fish` if you'd rather practise in fish; the real exam is bash).
The same state is available from the terminal if you prefer: The same state is available from the terminal if you prefer:
@ -74,13 +108,15 @@ The same state is available from the terminal if you prefer:
./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
./exam --track ckad web # other tracks (or CKA_TRACK=ckad) ./exam --track ckad web # other tracks and their clusters (or CKA_TRACK=ckad)
./teardown.sh # delete cluster + state (history kept) ./exam clusters # Kubernetes version and cluster per track
./teardown.sh [cka|ckad|cks|legacy] # delete clusters + state (history kept; default all)
``` ```
## CKAD track ## CKAD track
`./exam --track ckad ...` (every command takes it). A mock draws **16** tasks in 120 minutes, `./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,
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 37 (8/7/7/10/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 one task: image builds, Jobs, DaemonSets, native sidecars, line of the curriculum has at least one task: image builds, Jobs, DaemonSets, native sidecars,
@ -118,7 +154,9 @@ but left out of every statistic.
## Using your own cluster instead of kind ## Using your own cluster instead of kind
Any kubeadm-style cluster with 1 control-plane + 3 workers works. Skip `setup.sh`, set (Fish): Any kubeadm-style cluster with 1 control-plane + 3 workers works. Skip `setup.sh`, set (Fish) —
these override the cluster of every track, so use one on the minor of the track you practise
(session start warns when the version differs):
``` ```
set -x CKA_CONTEXT kubernetes-admin@kubernetes set -x CKA_CONTEXT kubernetes-admin@kubernetes
@ -136,7 +174,8 @@ The etcd tasks (2 and 45) need `etcdctl`/`etcdutl` **on the control-plane node**
exam has them — they cannot be copied out of the running pod, since the etcd image is distroless. exam has them — they cannot be copied out of the running pod, since the etcd image is distroless.
`setup.sh` installs them on kind; on any cluster, seeding those tasks installs them if they are `setup.sh` installs them on kind; on any cluster, seeding those tasks installs them if they are
missing (from the etcd image in the node's containerd, else the upstream release, else apt) and missing (from the etcd image in the node's containerd, else the upstream release, else apt) and
fails loudly if it cannot. `./etcd-tools.sh` does the same by hand. fails loudly if it cannot. `./etcd-tools.sh [track]` does the same by hand. The CKA cluster runs
etcd 3.6, as the exam does: `etcdutl` restores and inspects snapshots, `etcdctl` only saves them.
## CKA bank ## CKA bank
@ -182,5 +221,6 @@ Banks live in `tracks/cka/{questions,bank_extra,bank_hard,bank_sim_*}.py`; a tas
Scripts get `$K` (pinned to the grading context), `$WS`, `$CP/$W1/$W2/$W3`, `nx NODE 'cmd'`, Scripts get `$K` (pinned to the grading context), `$WS`, `$CP/$W1/$W2/$W3`, `nx NODE 'cmd'`,
and helpers `j <get args> '{jsonpath}'` and `eq a b`. Task text may use `{WS}`, `{CP}`, `{W2}`, and helpers `j <get args> '{jsonpath}'` and `eq a b`. Task text may use `{WS}`, `{CP}`, `{W2}`,
`{W3}`, `{SHELL_CP}` etc. `TRACK["quota"]` / `["quota_hard"]` set how many tasks per domain a `{W3}`, `{SHELL_CP}` etc. `TRACK["quota"]` / `["quota_hard"]` set how many tasks per domain a
session draws. For CKS, fill in `QUESTIONS` in the session draws, `TRACK["k8s"]` the Kubernetes minor (and so the cluster) the track runs on, and
existing placeholder track; `TRACK` already carries the domain table and pass mark. `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.

View File

@ -3,9 +3,11 @@
# Q02 (snapshot) / Q102 (restore) need them there). They cannot be copied out of the running # Q02 (snapshot) / Q102 (restore) need them there). They cannot be copied out of the running
# etcd pod: registry.k8s.io/etcd is distroless, so there is no cat, sh or tar inside it. # etcd pod: registry.k8s.io/etcd is distroless, so there is no cat, sh or tar inside it.
# Re-run this after ./setup.sh if the install was skipped or the network was down. # Re-run this after ./setup.sh if the install was skipped or the network was down.
# ./etcd-tools.sh install them on the control-plane node (podman/docker clusters) # ./etcd-tools.sh [TRACK] install them on the control-plane node of TRACK's cluster (default: the
# track ./exam defaults to; CKA_CP=<node> names the node directly)
# ./etcd-tools.sh --emit print the node-side script; ./exam pipes it through CKA_NODE_ACCESS, # ./etcd-tools.sh --emit print the node-side script; ./exam pipes it through CKA_NODE_ACCESS,
# so ssh/vagrant clusters get the same install when a task is seeded # so ssh/vagrant clusters get the same install when a task is seeded
# Version-agnostic: it takes whatever etcd the node runs (3.6.x on v1.35, 3.7.x on v1.37).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -67,6 +69,6 @@ fi
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}" RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman [ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
CP="${CKA_CP:-cka-sim-control-plane}" CP="${CKA_CP:-$(./exam ${1:+--track "$1"} cluster-env | sed -n 's/^CLUSTER=//p')-control-plane}"
printf '%s: ' "$CP" printf '%s: ' "$CP"
printf '%s\n' "$NODE_SCRIPT" | "$RT" exec -i "$CP" sh -s printf '%s\n' "$NODE_SCRIPT" | "$RT" exec -i "$CP" sh -s

243
exam
View File

@ -38,6 +38,23 @@ PASS_PCT = 0
STATE_FILE = "" STATE_FILE = ""
HISTORY_FILE = "" HISTORY_FILE = ""
WEAK_FILE = "" WEAK_FILE = ""
K8S = "" # Kubernetes minor the track's real exam runs, e.g. "v1.35"
CLUSTER = "" # kind cluster for that minor
CONTEXT = ""
NODES = {}
# One kind cluster per Kubernetes minor an exam runs on, so tracks on the same minor share one
# (CKA and CKS today) and moving an exam to a new minor is a one-line change to its TRACK["k8s"].
# The images are the ones the kind v0.33.0 release notes publish; kind only guarantees an image
# built for its own release when it is pinned by digest.
KIND_IMAGES = {
"v1.35": "kindest/node:v1.35.8@sha256:07b2536e30b803ed61d1677a79df6115f798ce64c80f9e22f6ed45afd09323c0",
"v1.37": "kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5",
}
def kind_cluster(k8s):
"""Name of the kind cluster for a Kubernetes minor: v1.35 -> cka-sim-135."""
return "cka-sim-" + k8s.lstrip("v").replace(".", "")
def load_track(track_id): def load_track(track_id):
@ -45,13 +62,14 @@ def load_track(track_id):
try: try:
mod = importlib.import_module(f"tracks.{track_id}.questions") mod = importlib.import_module(f"tracks.{track_id}.questions")
except ModuleNotFoundError: except ModuleNotFoundError:
print(f"unknown track '{track_id}' (no tracks/{track_id}/questions.py)"); sys.exit(1) 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)
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")
q.setdefault("set", None) q.setdefault("set", None)
cluster = kind_cluster(track["k8s"])
g = globals() g = globals()
g.update( g.update(
TRACK_ID=track_id, TRACK=track, QUESTIONS=questions, DOMAINS=track["domains"], TRACK_ID=track_id, TRACK=track, QUESTIONS=questions, DOMAINS=track["domains"],
@ -63,6 +81,13 @@ 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"),
# The CKA_* variables point every track at your own cluster instead (see the README).
K8S=track["k8s"], CLUSTER=cluster,
CONTEXT=os.environ.get("CKA_CONTEXT", f"kind-{cluster}"),
NODES={"CP": os.environ.get("CKA_CP", f"{cluster}-control-plane"),
"W1": os.environ.get("CKA_W1", f"{cluster}-worker"),
"W2": os.environ.get("CKA_W2", f"{cluster}-worker2"),
"W3": os.environ.get("CKA_W3", f"{cluster}-worker3")},
) )
@ -81,20 +106,17 @@ DRILL_SIZE = 5
import random import random
NAMESPACES_TO_SETTLE = ["apps", "netpol", "svc", "ingress-app", "dns-check", "storage"] NAMESPACES_TO_SETTLE = ["apps", "netpol", "svc", "ingress-app", "dns-check", "storage"]
# --- cluster targeting (override with env vars to use a non-kind cluster) --- # --- cluster targeting: CONTEXT and NODES follow the track (load_track); env vars override ---
CONTEXT = os.environ.get("CKA_CONTEXT", "kind-cka-sim")
def _default_access(): def _default_access():
try: try:
return open(os.path.join(STATE_DIR, "runtime")).read().strip() # written by setup.sh return open(os.path.join(STATE_DIR, "runtime")).read().strip() # written by setup.sh
except OSError: except OSError:
return "podman" return "podman"
NODE_ACCESS = os.environ.get("CKA_NODE_ACCESS", _default_access()) # podman | docker | ssh | vagrant NODE_ACCESS = os.environ.get("CKA_NODE_ACCESS", _default_access()) # podman | docker | ssh | vagrant
NODES = { # kubectl matching each cluster's server version, fetched by setup.sh: .state/kubectl/v1.35/kubectl.
"CP": os.environ.get("CKA_CP", "cka-sim-control-plane"), # .state/kubectl/current links to the one for the cluster of the latest session; examrc puts it
"W1": os.environ.get("CKA_W1", "cka-sim-worker"), # first on PATH, so your terminals switch kubectl version together with the cluster.
"W2": os.environ.get("CKA_W2", "cka-sim-worker2"), KUBECTL_DIR = os.path.join(STATE_DIR, "kubectl")
"W3": os.environ.get("CKA_W3", "cka-sim-worker3"),
}
def shell_hint(node): def shell_hint(node):
return {"podman": f"podman exec -it {node} bash", return {"podman": f"podman exec -it {node} bash",
"docker": f"docker exec -it {node} bash", "docker": f"docker exec -it {node} bash",
@ -103,13 +125,12 @@ def shell_hint(node):
PRELUDE = r""" PRELUDE = r"""
set -uo pipefail set -uo pipefail
K="kubectl --context ${CKA_CONTEXT:-kind-cka-sim}" # grading never depends on the candidate's current context K="kubectl --context ${CKA_CONTEXT:?}" # grading never depends on the candidate's current context
WS="$WORKSPACE" WS="$WORKSPACE"
# j [kubectl get args...] '{jsonpath}' -> prints the jsonpath result ("" on error) # j [kubectl get args...] '{jsonpath}' -> prints the jsonpath result ("" on error)
j() { local n=$#; local path="${!n}"; $K get "${@:1:n-1}" -o "jsonpath=$path" 2>/dev/null; } j() { local n=$#; local path="${!n}"; $K get "${@:1:n-1}" -o "jsonpath=$path" 2>/dev/null; }
eq() { [ "$1" = "$2" ]; } eq() { [ "$1" = "$2" ]; }
CP="${CKA_CP:-cka-sim-control-plane}"; W1="${CKA_W1:-cka-sim-worker}" CP="${CKA_CP:?}"; W1="${CKA_W1:?}"; W2="${CKA_W2:?}"; W3="${CKA_W3:?}" # run() exports the track's nodes
W2="${CKA_W2:-cka-sim-worker2}"; W3="${CKA_W3:-cka-sim-worker3}"
# nx NODE 'command' -> run a shell command on a node (stdin is forwarded) # nx NODE 'command' -> run a shell command on a node (stdin is forwarded)
nx() { nx() {
local n="$1"; shift local n="$1"; shift
@ -131,18 +152,20 @@ ensure_etcd_tools() {
# Q74 need them at seed time; a cluster rebuilt by ./setup.sh has none until one of them installs it. # Q74 need them at seed time; a cluster rebuilt by ./setup.sh has none until one of them installs it.
ensure_gateway_crds() { ensure_gateway_crds() {
$K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 && return 0 $K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 && return 0
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml -o /tmp/gwapi.yaml \ curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml \
&& $K apply -f /tmp/gwapi.yaml >/dev/null 2>&1 \ | $K apply -f - >/dev/null 2>&1 \
&& $K wait --for=condition=Established --timeout=60s crd/gatewayclasses.gateway.networking.k8s.io \ && $K wait --for=condition=Established --timeout=60s crd/gatewayclasses.gateway.networking.k8s.io \
crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io >/dev/null 2>&1 crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io >/dev/null 2>&1
} }
# ensure_metrics_server -> a working metrics API for `kubectl top` (CKAD Q23). kind ships none, and # ensure_metrics_server -> a working metrics API for `kubectl top` (CKAD Q23). kind ships none, and
# kind's kubelet serving certs have no IP SANs, so it only becomes Ready with --kubelet-insecure-tls. # kind's kubelet serving certs have no IP SANs, so it only becomes Ready with --kubelet-insecure-tls.
# CKA Q23 strips that flag on purpose; the baseline puts it back before every session. # CKA Q23 strips that flag on purpose; the baseline puts it back before every session.
# Pinned: v0.9.x supports Kubernetes 1.34+, so it serves the v1.35 and v1.37 clusters alike, and a
# future "latest" that drops 1.35 cannot break the CKA track.
METRICS_SERVER_URL=https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.9.0/components.yaml
ensure_metrics_server() { ensure_metrics_server() {
if ! $K -n kube-system get deploy metrics-server >/dev/null 2>&1; then if ! $K -n kube-system get deploy metrics-server >/dev/null 2>&1; then
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml -o /tmp/ms.yaml \ curl -sfL --max-time 60 "$METRICS_SERVER_URL" | $K apply -f - >/dev/null 2>&1 || return 1
&& $K apply -f /tmp/ms.yaml >/dev/null 2>&1 || return 1
fi fi
if ! $K -n kube-system get deploy metrics-server -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q kubelet-insecure-tls; then if ! $K -n kube-system get deploy metrics-server -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q kubelet-insecure-tls; then
$K -n kube-system patch deploy metrics-server --type=json \ $K -n kube-system patch deploy metrics-server --type=json \
@ -168,8 +191,13 @@ def red(s): return c("31", s)
def yellow(s): return c("33", s) def yellow(s): return c("33", s)
def bold(s): return c("1", s) def bold(s): return c("1", s)
def dim(s): return c("2", s) def dim(s): return c("2", s)
class Die(SystemExit):
"""SystemExit that carries its message, so the web UI can show why a start was refused."""
def die(msg, code=1): def die(msg, code=1):
print(red(msg)); sys.exit(code) print(red(msg))
e = Die(code); e.msg = msg
raise e
# ------------------------------------------------------------- state ---- # ------------------------------------------------------------- state ----
def load_state() -> dict[str, Any] | None: def load_state() -> dict[str, Any] | None:
@ -220,6 +248,17 @@ def require_started() -> dict[str, Any]:
assert st is not None assert st is not None
return st return st
_TRACK_K8S: dict[str, str] = {}
def track_k8s():
"""Each track's Kubernetes minor, for the lobby's track picker (looked up once per process)."""
if not _TRACK_K8S:
for t in TRACKS:
try:
_TRACK_K8S[t] = importlib.import_module(f"tracks.{t}.questions").TRACK.get("k8s", "?")
except Exception: # noqa: BLE001 - a bank mid-edit must not take the other tracks' UI down
_TRACK_K8S[t] = "?"
return _TRACK_K8S
def session_questions(st: dict[str, Any] | None) -> list[dict[str, Any]]: def session_questions(st: dict[str, Any] | None) -> list[dict[str, Any]]:
if not st: if not st:
return list(QUESTIONS) return list(QUESTIONS)
@ -269,19 +308,34 @@ def make_params(q, rng):
return fn(rng) if fn else {} return fn(rng) if fn else {}
# -------------------------------------------------------------- shell ---- # -------------------------------------------------------------- shell ----
def kubectl_path():
"""PATH with the kubectl matching this track's Kubernetes minor first, when setup.sh fetched one."""
d = os.path.join(KUBECTL_DIR, K8S)
path = os.environ.get("PATH", "")
return f"{d}{os.pathsep}{path}" if os.path.isfile(os.path.join(d, "kubectl")) else path
def run(script, timeout=180, params=None): def run(script, timeout=180, params=None):
env = dict(os.environ, WORKSPACE=WORKSPACE, CKA_ROOT=ROOT, CKA_NODE_ACCESS=NODE_ACCESS, CKA_CONTEXT=CONTEXT, env = dict(os.environ, WORKSPACE=WORKSPACE, CKA_ROOT=ROOT, CKA_NODE_ACCESS=NODE_ACCESS, CKA_CONTEXT=CONTEXT,
**{f"CKA_{k}": v for k, v in NODES.items()}) PATH=kubectl_path(), **{f"CKA_{k}": v for k, v in NODES.items()})
p = subprocess.run(["bash", "-c", PRELUDE + render(script, params)], env=env, capture_output=True, text=True, p = subprocess.run(["bash", "-c", PRELUDE + render(script, params)], env=env, capture_output=True, text=True,
timeout=timeout, stdin=subprocess.DEVNULL) timeout=timeout, stdin=subprocess.DEVNULL)
return p.returncode, (p.stdout + p.stderr).strip() return p.returncode, (p.stdout + p.stderr).strip()
def kubectl_ok(): def server_version():
"""The cluster's gitVersion (e.g. v1.35.8), or None when it cannot be reached."""
try: try:
return subprocess.run(["kubectl", "--context", CONTEXT, "get", "--raw=/version"], p = subprocess.run(["kubectl", "--context", CONTEXT, "get", "--raw=/version"],
capture_output=True, text=True, timeout=15).returncode == 0 capture_output=True, text=True, timeout=15)
return json.loads(p.stdout).get("gitVersion") if p.returncode == 0 else None
except Exception: except Exception:
return False return None
def kubectl_ok():
return server_version() is not None
def setup_hint():
"""How to (re)build this track's cluster."""
return f"./setup.sh {TRACK_ID}"
def grade_one(q, params=None): def grade_one(q, params=None):
"""Run every check independently. Returns a result dict with partial credit.""" """Run every check independently. Returns a result dict with partial credit."""
@ -322,6 +376,7 @@ def load_history(include_synthetic=False):
def record_attempt(st): def record_attempt(st):
earned, total, pct = score(st) earned, total, pct = score(st)
entry = {"at": datetime.now().isoformat(timespec="minutes"), "mode": st.get("mode", "exam"), "hard": st.get("hard", False), entry = {"at": datetime.now().isoformat(timespec="minutes"), "mode": st.get("mode", "exam"), "hard": st.get("hard", False),
"k8s": st.get("k8s"), # server version it ran on; older entries predate per-track clusters
"ids": [q["id"] for q in session_questions(st)], "score_pct": pct, "earned": earned, "total": total, "ids": [q["id"] for q in session_questions(st)], "score_pct": pct, "earned": earned, "total": total,
"time_used": int(st["ended_at"] - st["started_at"] - paused_total(st)), "time_used": int(st["ended_at"] - st["started_at"] - paused_total(st)),
"results": {k: {"pass": v["pass"], "earned": v["earned"], "total": v["total"], "results": {k: {"pass": v["pass"], "earned": v["earned"], "total": v["total"],
@ -595,10 +650,15 @@ def preflight():
rc, _ = run('nx "$CP" "true" </dev/null && nx "$W1" "true" </dev/null && nx "$W2" "true" </dev/null && nx "$W3" "true" </dev/null', timeout=60) rc, _ = run('nx "$CP" "true" </dev/null && nx "$W1" "true" </dev/null && nx "$W2" "true" </dev/null && nx "$W3" "true" </dev/null', timeout=60)
if rc != 0: if rc != 0:
die(f"Cannot run commands on nodes with CKA_NODE_ACCESS={NODE_ACCESS}.\n" die(f"Cannot run commands on nodes with CKA_NODE_ACCESS={NODE_ACCESS}.\n"
" podman/docker -> node names must be kind container names (kind get nodes --name cka-sim)\n" f" podman/docker -> node names must be kind container names (podman ps --filter label=io.x-k8s.kind.cluster={CLUSTER})\n"
" ssh -> passwordless ssh to each node name must work\n" " ssh -> passwordless ssh to each node name must work\n"
" vagrant -> run from the Vagrantfile directory; names are vagrant machine names") " vagrant -> run from the Vagrantfile directory; names are vagrant machine names")
print(dim(f" track: {TRACK_ID} cluster: {CONTEXT} node access: {NODE_ACCESS} nodes: {', '.join(NODES.values())}")) ver = server_version() or "?"
print(dim(f" track: {TRACK_ID} cluster: {CONTEXT} ({ver}) node access: {NODE_ACCESS} nodes: {', '.join(NODES.values())}"))
# The real exams pin one minor each; practising on another one teaches the wrong defaults.
if not ver.startswith(K8S + "."):
fix = f"rebuild it: {setup_hint()} --recreate" if CONTEXT == f"kind-{CLUSTER}" else "point CKA_CONTEXT at a cluster on that minor"
print(yellow(f" warning: this cluster runs {ver}, but the real {TRACK_ID.upper()} exam runs Kubernetes {K8S} - {fix}"))
for tool, why in (("helm", "Helm tasks (the real exam has helm on the terminal)"), ("openssl", "certificate tasks"), ("curl", "tasks that fetch manifests")): for tool, why in (("helm", "Helm tasks (the real exam has helm on the terminal)"), ("openssl", "certificate tasks"), ("curl", "tasks that fetch manifests")):
if not shutil.which(str(tool)): if not shutil.which(str(tool)):
print(yellow(f" warning: '{tool}' not found on this machine - needed for {why}")) print(yellow(f" warning: '{tool}' not found on this machine - needed for {why}"))
@ -628,7 +688,13 @@ def setup_contexts(hard, params_by_id):
`kubectl config use-context k8s-c?-?` and are unfollowable without it - not only when the `kubectl config use-context k8s-c?-?` and are unfollowable without it - not only when the
whole session was started with --hard. A drill that names hard task ids explicitly whole session was started with --hard. A drill that names hard task ids explicitly
('./exam drill 121', './exam drill weak') pulls those tasks in with hard=False. ('./exam drill 121', './exam drill weak') pulls those tasks in with hard=False.
Only tracks with TRACK["exam_contexts"] (CKA) use them, so only those tracks create or delete
them: the others run on another cluster, and must not repoint or remove the CKA contexts.
""" """
if not TRACK.get("exam_contexts"):
subprocess.run(["kubectl", "config", "use-context", CONTEXT], capture_output=True)
return
cluster = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.cluster}}"], cluster = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.cluster}}"],
capture_output=True, text=True).stdout.strip() capture_output=True, text=True).stdout.strip()
user = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.user}}"], user = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.user}}"],
@ -675,7 +741,22 @@ if nx "$CP" 'grep -q "^ *- kube-apiserverr$" /etc/kubernetes/manifests/kube-apis
nx "$CP" "$MEDIT"' nx "$CP" "$MEDIT"'
medit /etc/kubernetes/manifests/kube-apiserver.yaml -e "s|^\( *\)- kube-apiserverr$|\1- kube-apiserver|"' </dev/null medit /etc/kubernetes/manifests/kube-apiserver.yaml -e "s|^\( *\)- kube-apiserverr$|\1- kube-apiserver|"' </dev/null
# the kubelet re-reads the manifest on its own; wait for the API to answer again # the kubelet re-reads the manifest on its own; wait for the API to answer again
for _ in $(seq 1 30); do kubectl --context "${CKA_CONTEXT:-kind-cka-sim}" get --raw=/readyz >/dev/null 2>&1 && break; sleep 2; done for _ in $(seq 1 30); do $K get --raw=/readyz >/dev/null 2>&1 && break; sleep 2; done
fi
# apiserver audit flags (CKS Q9, whose track shares this cluster with CKA): a half-finished answer -
# flags without the volume, or a hostPath of type File whose policy file is gone - keeps the API server
# down for every later session. Touched only when the API server is down and audit flags are present,
# so a working audit setup is left alone.
if nx "$CP" 'grep -q -- "--audit-" /etc/kubernetes/manifests/kube-apiserver.yaml' </dev/null 2>/dev/null; then
for _ in $(seq 1 10); do $K get --raw=/readyz >/dev/null 2>&1 && break; sleep 3; done
if ! $K get --raw=/readyz >/dev/null 2>&1; then
nx "$CP" 'mkdir -p /var/log/kubernetes
[ -f /etc/kubernetes/audit-policy.yaml ] || printf "apiVersion: audit.k8s.io/v1\nkind: Policy\nrules:\n- level: None\n" > /etc/kubernetes/audit-policy.yaml' </dev/null
nx "$CP" "$MEDIT"'
medit /etc/kubernetes/manifests/kube-apiserver.yaml -e "/--audit-/d"' </dev/null
for _ in $(seq 1 45); do $K get --raw=/readyz >/dev/null 2>&1 && break; sleep 2; done
fi
fi fi
# scheduler: undo the "kube-scheduler" -> "kube-schedulerr" command typo (Q20/Q61), and put the # scheduler: undo the "kube-scheduler" -> "kube-schedulerr" command typo (Q20/Q61), and put the
@ -828,12 +909,69 @@ def seed(qs, log: Callable[..., Any] = _log_msg, params_by_id=None):
for q in disruptive: for q in disruptive:
one(q) one(q)
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False): def open_sessions_elsewhere():
"""Tracks other than this one with a session that has not ended. Sessions share the kubeconfig's
current context (and kubectl version), so starting one would pull the other's terminal over to
another cluster while its grading stays on the old one: silent zeros."""
out = []
for t in TRACKS:
if t == TRACK_ID:
continue
try:
with open(os.path.join(STATE_DIR, f"state-{t}.json")) as f:
if not json.load(f).get("ended_at"):
out.append(t)
except (OSError, ValueError):
pass
return out
def use_kubectl():
"""Point .state/kubectl/current at the kubectl for this track's minor, or at your own kubectl
('host') when setup.sh could not fetch one. examrc puts current/ first on PATH; it always exists
once setup.sh has run, so a shell that already hashed current/kubectl follows the switch."""
if not os.path.isdir(KUBECTL_DIR):
return
target = K8S
if not os.path.isfile(os.path.join(KUBECTL_DIR, K8S, "kubectl")):
target = "host"
mine = os.path.realpath(KUBECTL_DIR) # never our own current/ - PATH may spell it via a symlink
own = shutil.which("kubectl", path=os.pathsep.join(
p for p in os.environ.get("PATH", "").split(os.pathsep) if not os.path.realpath(p).startswith(mine)))
if not own:
return
link = os.path.join(KUBECTL_DIR, "host", "kubectl")
os.makedirs(os.path.dirname(link), exist_ok=True)
if os.path.realpath(link) != os.path.realpath(own):
if os.path.lexists(link):
os.remove(link)
os.symlink(os.path.realpath(own), link)
cur = os.path.join(KUBECTL_DIR, "current")
tmp = cur + ".new"
try:
if os.path.lexists(tmp):
os.remove(tmp)
os.symlink(target, tmp)
os.replace(tmp, cur) # atomic: a terminal never sees current/ missing
except OSError:
pass
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False, other_ok=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.")
others = open_sessions_elsewhere()
if others and not (force if other_ok is None else other_ok):
t = others[0]
how = (f"switch to {t.upper()} in the lobby and submit it, or run ./exam --track {t} end (abort --yes discards it)"
if other_ok is False else f"./exam --track {t} end (or abort --yes), or add --force here")
die(f"A {t.upper()} session is still open. Tracks run on their own clusters but share your kubectl context,\n"
f" so finish that one first: {how}.")
if others:
log(yellow(f" warning: the open {'/'.join(o.upper() for o in others)} session loses its kubectl context and kubectl"
f" version to this {TRACK_ID.upper()} session; end it before going back to it."))
if not kubectl_ok(): if not kubectl_ok():
die(f"Cannot reach the cluster through kubectl context '{CONTEXT}'. Run ./setup.sh (kind) or set CKA_CONTEXT for another cluster.") die(f"Cannot reach the {TRACK_ID.upper()} cluster through kubectl context '{CONTEXT}' (Kubernetes {K8S}).\n"
f" Create or restart it: {setup_hint()} (or set CKA_CONTEXT for your own cluster)")
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()
@ -841,17 +979,19 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
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}
setup_contexts(hard, params_by_id) setup_contexts(hard, params_by_id)
use_kubectl()
restore_baseline(log) restore_baseline(log)
log(bold(f"Seeding {len(qs)} scenario(s){' [HARD]' if hard else ''}...")) log(bold(f"Seeding {len(qs)} scenario(s){' [HARD]' if hard else ''}..."))
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,
"results": {}, "flags": [], "notes": ""} "k8s": server_version(), "results": {}, "flags": [], "notes": ""}
save_state(st) save_state(st)
log("") log("")
log(bold("=" * 64)) log(bold("=" * 64))
log(bold(f" {TRACK_ID.upper()} {'EXAM' if mode == 'exam' else 'DRILL'}{' · HARD' if hard else ''} - clock started")) log(bold(f" {TRACK_ID.upper()} {'EXAM' if mode == 'exam' else 'DRILL'}{' · HARD' if hard else ''} - clock started"))
if hard: log(f" Cluster : {CONTEXT} (Kubernetes {st['k8s'] or K8S}, as the real {TRACK_ID.upper()} runs {K8S})")
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"))
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)} Pass mark: {PASS_PCT}%")
@ -1105,6 +1245,9 @@ def cmd_end(args):
st = require_started() st = require_started()
if st.get("ended_at"): if st.get("ended_at"):
die("Session already ended and recorded.") die("Session already ended and recorded.")
if not kubectl_ok():
die(f"Cannot reach the {TRACK_ID.upper()} cluster ({CONTEXT}), so every check would fail and a 0% attempt would be recorded.\n"
f" Start it first: {setup_hint()} or discard the session: ./exam abort --yes")
if remaining(st) <= 0: if remaining(st) <= 0:
print(yellow("Note: time expired before submission.")) print(yellow("Note: time expired before submission."))
entry = finish_session(st) entry = finish_session(st)
@ -1131,7 +1274,7 @@ def cmd_abort(args):
setup_contexts(False, {}) setup_contexts(False, {})
print(yellow("Session discarded. Nothing was graded or written to history.")) print(yellow("Session discarded. Nothing was graded or written to history."))
print(dim(f" kubectl context restored to {CONTEXT}. Seeded objects are still on the cluster;")) print(dim(f" kubectl context restored to {CONTEXT}. Seeded objects are still on the cluster;"))
print(dim(" './exam drill ...' or './exam start' re-seeds, './teardown.sh' removes the cluster.")) print(dim(f" './exam drill ...' or './exam start' re-seeds, './teardown.sh {TRACK_ID}' removes the cluster."))
def cmd_weak(args): def cmd_weak(args):
agg = stats(); hist = load_history() agg = stats(); hist = load_history()
@ -1239,9 +1382,9 @@ class Api(http.server.BaseHTTPRequestHandler):
def work(): def work():
try: try:
if body.get("sim") in SETS: if body.get("sim") in SETS:
begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True) begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True, other_ok=False)
elif mode == "exam": elif mode == "exam":
begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard) begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard, other_ok=False)
else: else:
if body.get("domain") in DOMAINS: if body.get("domain") in DOMAINS:
qs = pick(body["domain"], DRILL_SIZE, hard, rng) qs = pick(body["domain"], DRILL_SIZE, hard, rng)
@ -1256,9 +1399,10 @@ 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"))) untimed_session=bool(body.get("study")), other_ok=False)
except SystemExit: except SystemExit as e:
Api.seed_error = "\n".join(l for l in Api.log_lines if l.strip()) or "start failed - see the terminal running ./exam web" 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")
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
Api.seed_error = f"{type(e).__name__}: {e}" Api.seed_error = f"{type(e).__name__}: {e}"
finally: finally:
@ -1283,6 +1427,8 @@ class Api(http.server.BaseHTTPRequestHandler):
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)
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
return self._json({"error": f"Cannot reach the {TRACK_ID.upper()} cluster ({CONTEXT}). Start it with {setup_hint()}, then submit."}, 503)
finish_session(st, log=lambda *_: None) finish_session(st, log=lambda *_: None)
elif p.path == "/api/reset": elif p.path == "/api/reset":
q = q_by_id(int(body["id"])); run(str(q.get("setup", "")), params=session_params(st, q)) q = q_by_id(int(body["id"])); run(str(q.get("setup", "")), params=session_params(st, q))
@ -1301,7 +1447,8 @@ class Api(http.server.BaseHTTPRequestHandler):
"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}
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, "drilled": drilled_domains(), "sets": {k: {"name": v, "count": len(set_questions(k))} for k, v in SETS.items()}, "tracks": TRACKS, "track_k8s": track_k8s(),
"cluster": CONTEXT, "drilled": drilled_domains(), "sets": {k: {"name": v, "count": len(set_questions(k))} for k, v in SETS.items()},
"seen": sorted(last_seen()), "seen": sorted(last_seen()),
"seeding": Api.seeding, "seed_error": Api.seed_error, "seeding": Api.seeding, "seed_error": Api.seed_error,
"session": None, "session": None,
@ -1384,11 +1531,27 @@ def cmd_web(args):
ttyd.terminate() ttyd.terminate()
def cmd_version(args): def cmd_version(args):
print(f"exam {VERSION} track={TRACK_ID} bank={len(QUESTIONS)} tasks python={sys.version.split()[0]}") print(f"exam {VERSION} track={TRACK_ID} k8s={K8S} cluster={CONTEXT} bank={len(QUESTIONS)} tasks python={sys.version.split()[0]}")
def cmd_cluster_env(args):
"""Shell assignments for this track's kind cluster - setup.sh, teardown.sh and the tool scripts
eval this, so the track -> Kubernetes version -> cluster/image mapping lives only here."""
print(f"TRACK={TRACK_ID}\nK8S={K8S}\nCLUSTER={CLUSTER}\nNODE_IMAGE={KIND_IMAGES.get(K8S, '')}")
def cmd_clusters(args):
"""Which cluster each track runs on, and whether it is up."""
print(bold(f"{'track':<7}{'exam runs':<11}{'kind cluster':<15}{'context':<19}status"))
for t in TRACKS:
load_track(t)
ver = server_version()
status = (green(f"up, {ver}") if ver and ver.startswith(K8S + ".") else yellow(f"up, but {ver}") if ver
else red(f"not reachable ({setup_hint()})"))
print(f"{t:<7}{K8S:<11}{CLUSTER:<15}{CONTEXT:<19}{status}")
def cmd_help(args): def cmd_help(args):
print(__doc__ + f"\nversion {VERSION}") print(__doc__ + f"\nversion {VERSION}")
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}.
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, nothing recorded (--count N, default {DRILL_SIZE}) study [DOMAIN|weak|N..] no clock, solutions unlocked, nothing recorded (--count N, default {DRILL_SIZE})
@ -1400,7 +1563,7 @@ def cmd_help(args):
sim {'|'.join(SETS) or '<set>'}{' ' * max(1, 20 - len('|'.join(SETS)))}fixed simulator set, exam rules, {TRACK['minutes']} min sim {'|'.join(SETS) or '<set>'}{' ' * max(1, 20 - len('|'.join(SETS)))}fixed simulator set, exam rules, {TRACK['minutes']} min
start [--hard] full exam: {sum(QUOTA.values()) or '?'} tasks sampled by domain ({sum(QUOTA_HARD.values()) or '?'} in hard mode), {TRACK['minutes']} min. start [--hard] full exam: {sum(QUOTA.values()) or '?'} tasks sampled by domain ({sum(QUOTA_HARD.values()) or '?'} in hard mode), {TRACK['minutes']} min.
Requires every domain drilled once first (--force skips). Hard mode adds traps, Requires every domain drilled once first (--force skips). Hard mode adds traps,
exact-output tasks, compound failures, collateral checks and exam-style contexts. exact-output tasks, compound failures{' and collateral checks' if not TRACK.get('exam_contexts') else ', collateral checks and exam-style contexts'}.
list tasks in this session, with current + historical results list tasks in this session, with current + historical results
show N display task N show N display task N
grade N|all grade one task (or all) per check - drills only (--force to peek in an exam) grade N|all grade one task (or all) per check - drills only (--force to peek in an exam)
@ -1410,11 +1573,13 @@ def cmd_help(args):
weak what you keep getting wrong, with the failing checks weak what you keep getting wrong, with the failing checks
history every attempt, scores, and per-domain trend history every attempt, scores, and per-domain trend
reset N re-seed the scenario for task N reset N re-seed the scenario for task N
clusters each track's Kubernetes version and cluster, and whether it is up
""") """)
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}
if __name__ == "__main__": if __name__ == "__main__":
cmd = ARGV[0] if ARGV else "help" cmd = ARGV[0] if ARGV else "help"

7
examrc
View File

@ -2,6 +2,13 @@
# and safe to source yourself: source ./examrc # and safe to source yourself: source ./examrc
[ -f ~/.bashrc ] && . ~/.bashrc [ -f ~/.bashrc ] && . ~/.bashrc
# kubectl at the Kubernetes version of the cluster your latest session ran on, ahead of your own:
# ./exam keeps .state/kubectl/current pointing at it (CKA/CKS v1.35, CKAD v1.37), as the real exams
# give you a kubectl that matches the cluster. Without ./setup.sh's download it is your own kubectl.
_cka_kubectl="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.state/kubectl/current"
case ":$PATH:" in *":$_cka_kubectl:"*) ;; *) export PATH="$_cka_kubectl:$PATH" ;; esac
unset _cka_kubectl
alias k=kubectl alias k=kubectl
export do='--dry-run=client -o yaml' # k create deploy x --image=nginx $do > x.yaml export do='--dry-run=client -o yaml' # k create deploy x --image=nginx $do > x.yaml
export now='--force --grace-period 0' # k delete pod x $now export now='--force --grace-period 0' # k delete pod x $now

View File

@ -1,4 +1,7 @@
# cka-sim shell setup for fish: source ./examrc.fish # cka-sim shell setup for fish: source ./examrc.fish
# kubectl matching the cluster of your latest session first on PATH (see examrc)
set -l _cka_kubectl (realpath (dirname (status filename)))/.state/kubectl/current
contains -- $_cka_kubectl $PATH; or set -gx PATH $_cka_kubectl $PATH
alias k kubectl alias k kubectl
set -gx KUBE_EDITOR (test -n "$KUBE_EDITOR"; and echo $KUBE_EDITOR; or echo vi) set -gx KUBE_EDITOR (test -n "$KUBE_EDITOR"; and echo $KUBE_EDITOR; or echo vi)
# lists, so they expand to separate arguments # lists, so they expand to separate arguments

View File

@ -1,6 +1,7 @@
# Topology shared by every cka-sim cluster. setup.sh passes --name and the pinned --image per
# Kubernetes minor (cka-sim-135 for CKA/CKS, cka-sim-137 for CKAD - see ./exam cluster-env).
kind: Cluster kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4 apiVersion: kind.x-k8s.io/v1alpha4
name: cka-sim
nodes: nodes:
- role: control-plane - role: control-plane
- role: worker - role: worker

View File

@ -1,11 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Install vim/nano/less/jq on every kind node (the real exam has an editor on each node). # Install vim/nano/less/jq on every kind node (the real exam has an editor on each node).
# Re-run this after ./setup.sh if the install was skipped or the network was down. # Re-run this after ./setup.sh if the install was skipped or the network was down.
# ./node-tools.sh [TRACK] the nodes of TRACK's cluster (default: the track ./exam defaults to)
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}" RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman [ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
for n in $(kind get nodes --name cka-sim); do cluster=$(./exam ${1:+--track "$1"} cluster-env | sed -n 's/^CLUSTER=//p')
for n in $(kind get nodes --name "$cluster"); do
printf '%s: ' "$n" printf '%s: ' "$n"
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then echo "already has vim"; continue; fi if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then echo "already has vim"; continue; fi
if "$RT" exec "$n" sh -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq vim nano less jq >/dev/null 2>&1'; then if "$RT" exec "$n" sh -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq vim nano less jq >/dev/null 2>&1'; then

158
setup.sh
View File

@ -1,5 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Bootstraps the cka-sim kind cluster (1 control-plane + 3 workers) on podman and preloads images. # Bootstraps the kind cluster(s) for the exam tracks on podman and preloads images.
# Each exam runs on one Kubernetes minor, and each minor gets its own cluster (1 control-plane +
# 3 workers), so you practise on the version you will sit:
# ./setup.sh [cka|ckad|cks|all] [--recreate]
# cka, cks -> cka-sim-135 (Kubernetes v1.35) ckad -> cka-sim-137 (Kubernetes v1.37)
# With no track it sets up the one ./exam defaults to ($CKA_TRACK, else cka). Idempotent: an
# existing cluster is reused (its nodes restarted if stopped); --recreate rebuilds one whose
# version is wrong. `./exam cluster-env` is the single source of the track -> version -> image map.
# Override with CONTAINER_RUNTIME=docker if you ever want docker instead. # Override with CONTAINER_RUNTIME=docker if you ever want docker instead.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@ -14,6 +21,20 @@ need kind "https://kind.sigs.k8s.io/docs/user/quick-start/#installation"
need kubectl "https://kubernetes.io/docs/tasks/tools/" need kubectl "https://kubernetes.io/docs/tasks/tools/"
need python3 "" need python3 ""
tracks=() recreate=0
while [ $# -gt 0 ]; do
case "$1" in
cka|ckad|cks) tracks+=("$1") ;;
all) tracks+=(cka ckad cks) ;;
--track) tracks+=("${2:?--track needs a value}"); shift ;;
--recreate) recreate=1 ;;
-h|--help) sed -n '2,10p' "$(basename "$0")" | sed 's/^# \{0,1\}//'; exit 0 ;; # cwd is the script dir
*) echo "unknown argument '$1'. usage: ./setup.sh [cka|ckad|cks|all] [--recreate]"; exit 1 ;;
esac
shift
done
[ ${#tracks[@]} -gt 0 ] || tracks=("${CKA_TRACK:-cka}")
if [ "$RT" = podman ]; then if [ "$RT" = podman ]; then
export KIND_EXPERIMENTAL_PROVIDER=podman export KIND_EXPERIMENTAL_PROVIDER=podman
podman info >/dev/null 2>&1 || { echo "ERROR: podman is not working (try: podman info)."; exit 1; } podman info >/dev/null 2>&1 || { echo "ERROR: podman is not working (try: podman info)."; exit 1; }
@ -31,16 +52,92 @@ else
docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon is not running/reachable."; exit 1; } docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon is not running/reachable."; exit 1; }
fi fi
if kind get clusters 2>/dev/null | grep -qx cka-sim; then # a session in progress keeps its kubectl context and kubectl version (kind create switches the context)
echo "==> kind cluster 'cka-sim' already exists, reusing it." prev_ctx=$(kubectl config current-context 2>/dev/null || true)
open_session=$(python3 -c 'import glob, json
for f in glob.glob(".state/state-*.json"):
try:
if not json.load(open(f)).get("ended_at"): print(f[13:-5])
except Exception: pass' | head -1)
# track -> cluster, deduplicated: CKA and CKS share the v1.35 cluster
clusters=() first_ctx=""
for t in "${tracks[@]}"; do
env_lines=$(./exam --track "$t" cluster-env) || exit 1
eval "$env_lines" # TRACK K8S CLUSTER NODE_IMAGE
[ -n "$NODE_IMAGE" ] || { echo "ERROR: no kind node image is pinned for Kubernetes $K8S (KIND_IMAGES in ./exam)."; exit 1; }
[ -n "$first_ctx" ] || first_ctx="kind-$CLUSTER"
case " ${clusters[*]-} " in *" $CLUSTER|"*) ;; *) clusters+=("$CLUSTER|$K8S|$NODE_IMAGE") ;; esac
done
# kubectl at the cluster's own version, as the real exam gives you: .state/kubectl/<minor>/kubectl.
# ./exam points .state/kubectl/current at the right one per session and examrc puts it on PATH.
# On Linux it comes out of the control-plane node, which ships exactly that kubectl (offline, no
# checksum needed); elsewhere the node's Linux binary will not run, so it is downloaded.
fetch_kubectl() {
local ver=$1 dir=.state/kubectl/$2 cp=$3 os arch url tmp want got
if [ -x "$dir/kubectl" ] && "$dir/kubectl" version --client 2>/dev/null | grep -q "Client Version: $ver\$"; then
echo " kubectl $ver already in $dir"; return 0
fi
os=$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
mkdir -p "$dir"; tmp=$(mktemp "$dir/.kubectl.XXXXXX")
if [ "$os" = linux ] && "$RT" cp "$cp:/usr/bin/kubectl" "$tmp" >/dev/null 2>&1 && chmod +x "$tmp" \
&& "$tmp" version --client 2>/dev/null | grep -q "Client Version: $ver\$"; then
mv -f "$tmp" "$dir/kubectl"; echo " kubectl $ver (from $cp) -> $dir/kubectl"; return 0
fi
url="https://dl.k8s.io/release/$ver/bin/$os/$arch/kubectl"
want=$(curl -sfL --max-time 30 "$url.sha256" || true)
if [ -n "$want" ] && curl -sfL --max-time 300 "$url" -o "$tmp"; then
got=$( (sha256sum "$tmp" 2>/dev/null || shasum -a 256 "$tmp") | awk '{print $1}')
if [ "$got" = "$want" ]; then
chmod +x "$tmp"; mv -f "$tmp" "$dir/kubectl"; echo " kubectl $ver -> $dir/kubectl"; return 0
fi
echo " warn: kubectl $ver checksum mismatch, discarded"
fi
rm -f "$tmp"
echo " warn: could not download kubectl $ver - your own kubectl ($(kubectl version --client 2>/dev/null | head -1)) will be used"
}
create() {
kind create cluster --name "$1" --config kind-config.yaml --image "${KIND_NODE_IMAGE:-$2}" --wait 180s
}
setup_cluster() {
local cluster=$1 k8s=$2 image=$3 ctx="kind-$1" cp="$1-control-plane" ver
echo
if kind get clusters 2>/dev/null | grep -qx "$cluster"; then
# rootless podman does not bring node containers back after a reboot or logout
for n in $(kind get nodes --name "$cluster"); do
[ "$("$RT" inspect -f '{{.State.Running}}' "$n" 2>/dev/null)" = true ] || { echo "==> starting stopped node $n"; "$RT" start "$n" >/dev/null; }
done
ver=$("$RT" exec "$cp" cat /kind/version 2>/dev/null || echo unknown)
case "$ver" in
"$k8s".*) echo "==> kind cluster '$cluster' already exists (Kubernetes $ver), reusing it." ;;
*)
if [ "$recreate" != 1 ]; then
echo "ERROR: kind cluster '$cluster' runs Kubernetes $ver, but its tracks need $k8s."
echo " Rebuild it (this deletes everything on it): ./setup.sh ${tracks[*]} --recreate"
exit 1
fi
echo "==> '$cluster' runs $ver, not $k8s - recreating it"
kind delete cluster --name "$cluster"
create "$cluster" "$image"
;;
esac
else else
echo "==> Creating kind cluster 'cka-sim' on $RT (1 control-plane + 3 workers)..." echo "==> Creating kind cluster '$cluster' (Kubernetes $k8s) on $RT (1 control-plane + 3 workers)..."
kind create cluster --config kind-config.yaml ${KIND_NODE_IMAGE:+--image "$KIND_NODE_IMAGE"} --wait 180s create "$cluster" "$image"
fi fi
kubectl config use-context kind-cka-sim >/dev/null
echo "==> Waiting for nodes..." echo "==> Waiting for nodes..."
kubectl wait --for=condition=Ready nodes --all --timeout=180s # `kubectl wait` gives up on a refused connection, and restarted nodes need 15-40 s before the
# API server answers again - wait for it first
local deadline=$((SECONDS + 180))
until kubectl --context "$ctx" get --raw=/readyz >/dev/null 2>&1; do
[ $SECONDS -lt $deadline ] || { echo "ERROR: the API server of '$cluster' did not come back within 180 s."; exit 1; }
sleep 3
done
kubectl --context "$ctx" wait --for=condition=Ready nodes --all --timeout=180s
echo "==> Preloading images into the cluster (non-fatal if a pull fails)..." echo "==> Preloading images into the cluster (non-fatal if a pull fails)..."
# `kind load docker-image` shells out to the docker CLI internally no matter which # `kind load docker-image` shells out to the docker CLI internally no matter which
@ -51,7 +148,7 @@ for img in nginx:1.25 nginx:1.27 busybox:1.36 redis:7-alpine; do
if "$RT" pull -q "docker.io/library/$img" >/dev/null 2>&1; then if "$RT" pull -q "docker.io/library/$img" >/dev/null 2>&1; then
tarfile="$(mktemp)" tarfile="$(mktemp)"
if "$RT" save -o "$tarfile" "docker.io/library/$img" >/dev/null 2>&1 \ if "$RT" save -o "$tarfile" "docker.io/library/$img" >/dev/null 2>&1 \
&& kind load image-archive --name cka-sim "$tarfile" >/dev/null 2>&1; then && kind load image-archive --name "$cluster" "$tarfile" >/dev/null 2>&1; then
echo " loaded $img" echo " loaded $img"
else else
echo " warn: could not load $img" echo " warn: could not load $img"
@ -63,13 +160,13 @@ for img in nginx:1.25 nginx:1.27 busybox:1.36 redis:7-alpine; do
done done
echo "==> Making sure the exam workdir exists on the control-plane node..." echo "==> Making sure the exam workdir exists on the control-plane node..."
"$RT" exec cka-sim-control-plane mkdir -p /opt/cka "$RT" exec "$cp" mkdir -p /opt/cka
# The real exam gives you vim (and less/jq) on every node; the kind node image is minimal, so # The real exam gives you vim (and less/jq) on every node; the kind node image is minimal, so
# install them once here. Skip with CKA_SKIP_NODE_TOOLS=1. # install them once here. Skip with CKA_SKIP_NODE_TOOLS=1.
if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then
echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..." echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..."
for n in $(kind get nodes --name cka-sim); do for n in $(kind get nodes --name "$cluster"); do
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then
echo " $n already has vim" echo " $n already has vim"
continue continue
@ -88,14 +185,45 @@ fi
# copied out of the running etcd pod (that image is distroless), so install them properly. # copied out of the running etcd pod (that image is distroless), so install them properly.
echo "==> Installing etcdctl/etcdutl on the control-plane node..." echo "==> Installing etcdctl/etcdutl on the control-plane node..."
printf ' ' printf ' '
CONTAINER_RUNTIME="$RT" bash ./etcd-tools.sh || echo " warn: no etcd client on the control-plane node; Q02/Q45 will say so when they seed" CONTAINER_RUNTIME="$RT" CKA_CP="$cp" bash ./etcd-tools.sh || echo " warn: no etcd client on $cp; Q02/Q45 will say so when they seed"
echo "==> Fetching kubectl to match the cluster..."
fetch_kubectl "$("$RT" exec "$cp" cat /kind/version)" "$k8s" "$cp"
echo
echo "Cluster '$cluster' ready (context $ctx). Nodes:"
kubectl --context "$ctx" get nodes -o wide
}
for c in "${clusters[@]}"; do
IFS='|' read -r cluster k8s image <<<"$c"
setup_cluster "$cluster" "$k8s" "$image"
done
# remember the runtime so the CLI uses the matching exec command # remember the runtime so the CLI uses the matching exec command
mkdir -p .state && echo "$RT" > .state/runtime mkdir -p .state && echo "$RT" > .state/runtime
# `kind create cluster` switches the current context to each new cluster; land on the first track's,
# with its kubectl (./exam re-points both at every session start) - unless a session is open, which
# keeps what it had
first_k8s=$(./exam --track "${tracks[0]}" cluster-env | sed -n 's/^K8S=//p')
mkdir -p .state/kubectl
if [ -n "$open_session" ]; then
[ -n "$prev_ctx" ] && kubectl config use-context "$prev_ctx" >/dev/null
echo "note: a $open_session session is still open, so your kubectl context stays $prev_ctx."
elif kubectl config use-context "$first_ctx" >/dev/null && [ -x ".state/kubectl/$first_k8s/kubectl" ]; then
ln -sfn "$first_k8s" .state/kubectl/current
else # no download: current/ -> your own kubectl, so the path examrc puts on PATH always exists
own=$(PATH=$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '/\.state/kubectl' | paste -sd: -) command -v kubectl)
mkdir -p .state/kubectl/host && ln -sfn "$(realpath "$own")" .state/kubectl/host/kubectl && ln -sfn host .state/kubectl/current
fi
if kind get clusters 2>/dev/null | grep -qx cka-sim; then
echo
echo "note: the old single cluster 'cka-sim' (from before each exam got its own version) still exists."
echo " Nothing uses it now; free its ~4 GB with: ./teardown.sh legacy"
fi
chmod +x exam chmod +x exam
echo echo
echo "Cluster ready. Nodes:" echo "Next: ./exam --track ${tracks[0]} web (browser UI) or ./exam --track ${tracks[0]} drill (5 tasks from your least-drilled domain)"
kubectl get nodes -o wide echo " ./exam clusters which cluster and Kubernetes version each track uses"
echo
echo "Next: ./exam web (browser UI) or ./exam drill (5 tasks from your least-drilled domain)"

View File

@ -1,11 +1,47 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Deletes the kind cluster and all local exam state. # Deletes kind cluster(s) and the local state of the tracks that ran on them (history is kept).
# ./teardown.sh every cka-sim cluster (cka-sim-135, cka-sim-137, any older minor's, and the
# old single cka-sim) + all state
# ./teardown.sh cka|cks the v1.35 cluster - CKA and CKS share it, so both their sessions go
# ./teardown.sh ckad the v1.37 cluster and the CKAD session
# ./teardown.sh legacy only the old single cluster 'cka-sim' from before per-version clusters
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}" RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman [ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
kind delete cluster --name cka-sim || true
# cluster of a track, or stop here: an empty name would make kind delete its default cluster "kind"
cluster_of() {
local c
c=$(./exam --track "$1" cluster-env | sed -n 's/^CLUSTER=//p') || exit 1
[ -n "$c" ] || { echo "ERROR: ./exam cluster-env gave no cluster for track $1" >&2; exit 1; }
echo "$c"
}
what="${1:-all}"
case "$what" in
all)
# every cka-sim cluster - also one left behind when an exam moved to a new minor - and nothing else
for c in $(kind get clusters 2>/dev/null | grep -E '^cka-sim(-[0-9]+)?$'); do
kind delete cluster --name "$c"
done
# Safe to wipe wholesale: everything under workspace/ is generated by task setup scripts and # Safe to wipe wholesale: everything under workspace/ is generated by task setup scripts and
# nothing there is tracked (see .gitignore). Anything you want to keep, copy out first. # nothing there is tracked (see .gitignore). Anything you want to keep, copy out first.
rm -rf .state workspace rm -rf .state workspace
echo "cka-sim cluster and local state removed." echo "All cka-sim clusters and local state removed (history kept)."
;;
legacy)
kind delete cluster --name cka-sim || true
echo "Old cluster 'cka-sim' removed."
;;
cka|ckad|cks)
cluster=$(cluster_of "$what")
kind delete cluster --name "$cluster" || true
# sessions seeded on that cluster are gone with it; other tracks' sessions and files stay
for t in cka ckad cks; do
[ "$(cluster_of "$t")" = "$cluster" ] && rm -f ".state/state-$t.json"
done
echo "Cluster '$cluster' and the state of the tracks on it removed (history kept)."
;;
*) echo "usage: ./teardown.sh [all|cka|ckad|cks|legacy]"; exit 1 ;;
esac

View File

@ -221,7 +221,7 @@ $K -n %{ns} create deploy %{dep} --image=nginx:1.25 --dry-run=client -o yaml | $
$K -n %{ns} set resources deploy %{dep} --requests=cpu=50m >/dev/null $K -n %{ns} set resources deploy %{dep} --requests=cpu=50m >/dev/null
$K -n %{ns} delete hpa %{dep} --ignore-not-found >/dev/null $K -n %{ns} delete hpa %{dep} --ignore-not-found >/dev/null
if ! $K -n kube-system get deploy metrics-server >/dev/null 2>&1; then if ! $K -n kube-system get deploy metrics-server >/dev/null 2>&1; then
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml -o /tmp/ms.yaml && $K apply -f /tmp/ms.yaml >/dev/null || echo "warn: could not download metrics-server manifest" curl -sfL --max-time 60 "$METRICS_SERVER_URL" | $K apply -f - >/dev/null || echo "warn: could not download metrics-server manifest"
fi fi
# make it broken (kind kubelets use self-signed serving certs) # make it broken (kind kubelets use self-signed serving certs)
$K -n kube-system get deploy metrics-server -o json 2>/dev/null | python3 -c ' $K -n kube-system get deploy metrics-server -o json 2>/dev/null | python3 -c '

View File

@ -59,7 +59,7 @@ would include the work you did on other tasks, so a restore is the last thing yo
""", """,
"setup": r""" "setup": r"""
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/backup.db' nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/backup.db'
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with ./etcd-tools.sh, then re-seed"; exit 1; } ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with: CKA_CP=$CP ./etcd-tools.sh (or ./etcd-tools.sh cka), then re-seed"; exit 1; }
$K -n default delete cm %{cm} --ignore-not-found >/dev/null $K -n default delete cm %{cm} --ignore-not-found >/dev/null
$K -n default create cm %{cm} --from-literal=proof=$(date +%s)-%{cm} >/dev/null $K -n default create cm %{cm} --from-literal=proof=$(date +%s)-%{cm} >/dev/null
out=$(nx "$CP" '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/cka/backup.db' 2>&1) \ out=$(nx "$CP" '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/cka/backup.db' 2>&1) \

View File

@ -895,16 +895,19 @@ grep -Eq 'kubeadm[[:space:]]+upgrade[[:space:]]+node' "$WS/q80/worker.txt" 2>/de
k version -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])' > {WS}/q80/current.txt k version -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])' > {WS}/q80/current.txt
k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion --no-headers > {WS}/q80/kubelets.txt k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion --no-headers > {WS}/q80/kubelets.txt
cat > {WS}/q80/steps.sh <<'EOF' cat > {WS}/q80/steps.sh <<'EOF'
apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.34.1-1.1 && apt-mark hold kubeadm sed -i 's|/v1.35/|/v1.36/|' /etc/apt/sources.list.d/kubernetes.list
apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.36.5-1.1 && apt-mark hold kubeadm
kubeadm upgrade plan kubeadm upgrade plan
kubeadm upgrade apply v1.34.1 -y kubeadm upgrade apply v1.36.5 -y
kubectl drain <control-plane> --ignore-daemonsets kubectl drain <control-plane> --ignore-daemonsets
apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.34.1-1.1 kubectl=1.34.1-1.1 && apt-mark hold kubelet kubectl apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.36.5-1.1 kubectl=1.36.5-1.1 && apt-mark hold kubelet kubectl
systemctl daemon-reload systemctl daemon-reload
systemctl restart kubelet systemctl restart kubelet
kubectl uncordon <control-plane> kubectl uncordon <control-plane>
EOF EOF
echo 'kubeadm upgrade node' > {WS}/q80/worker.txt echo 'kubeadm upgrade node' > {WS}/q80/worker.txt
# The cluster runs v1.35, so the next minor is 1.36 - and pkgs.k8s.io serves one apt repo per
# minor, so the repo line has to move to v1.36 before 1.36 packages exist for apt at all.
# kubeadm goes first because it drives the whole thing; the control plane is upgraded by # kubeadm goes first because it drives the whole thing; the control plane is upgraded by
# `upgrade apply`, and only then do the node binaries (kubelet/kubectl) follow, ending with # `upgrade apply`, and only then do the node binaries (kubelet/kubectl) follow, ending with
# uncordon. Workers run `kubeadm upgrade node` - they host no control-plane static pods, so # uncordon. Workers run `kubeadm upgrade node` - they host no control-plane static pods, so
@ -1068,8 +1071,8 @@ spec:
- {type: Pods, value: 2, periodSeconds: 60} - {type: Pods, value: 2, periodSeconds: 60}
EOF EOF
k -n route-planner get hpa route-planner-hpa k -n route-planner get hpa route-planner-hpa
# `kubectl autoscale` only emits CPU targets and no behavior block, so memory + behavior means # `kubectl autoscale` can set a memory target (--memory=70%) but has no flag for a behavior block,
# writing the v2 YAML by hand. Utilisation is a percentage OF THE REQUEST, so the Deployment must # so memory + behavior means writing the v2 YAML by hand (or autoscale, then kubectl edit). Utilisation is a percentage OF THE REQUEST, so the Deployment must
# declare a memory request or the HPA reports <unknown> and never scales. # declare a memory request or the HPA reports <unknown> and never scales.
""", """,
) )

View File

@ -237,8 +237,8 @@ task(
cluster (all Namespaces) sorted by creation time, using `kubectl`. Make it executable. cluster (all Namespaces) sorted by creation time, using `kubectl`. Make it executable.
2. Delete the kube-proxy Pod that runs on node `{W2}`. Write the events this caused to 2. Delete the kube-proxy Pod that runs on node `{W2}`. Write the events this caused to
`{WS}/q90/pod-deleted.log`. `{WS}/q90/pod-deleted.log`.
3. On node `{W2}`, stop **and remove** the kube-proxy **container** with `crictl` — leave the Pod 3. On node `{W2}`, stop the kube-proxy **container** with `crictl stop` — leave the Pod object
object alone. Write the events this caused to `{WS}/q90/container-killed.log`. alone. Write the events this caused to `{WS}/q90/container-killed.log`.
4. Which of the two actions made the Pod's `RESTARTS` counter go up? Write `pod` or `container` to 4. Which of the two actions made the Pod's `RESTARTS` counter go up? Write `pod` or `container` to
`{WS}/q90/restarts.txt`. `{WS}/q90/restarts.txt`.
@ -283,7 +283,7 @@ k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-pr
{SHELL_W2} {SHELL_W2}
crictl ps --name kube-proxy # container id crictl ps --name kube-proxy # container id
crictl stop <id> && crictl rm <id> crictl stop <id>
exit exit
k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-proxy | tail -4 > {WS}/q90/container-killed.log k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-proxy | tail -4 > {WS}/q90/container-killed.log
# Pulled, Created, Started - same Pod, new container # Pulled, Created, Started - same Pod, new container
@ -291,6 +291,8 @@ k -n kube-system get pod -l k8s-app=kube-proxy -o wide # RESTARTS 1
echo container > {WS}/q90/restarts.txt echo container > {WS}/q90/restarts.txt
# Deleting the Pod makes the DaemonSet create a brand-new Pod (RESTARTS 0). Killing only the # Deleting the Pod makes the DaemonSet create a brand-new Pod (RESTARTS 0). Killing only the
# container makes the kubelet start a new container inside the SAME Pod - that is a restart. # container makes the kubelet start a new container inside the SAME Pod - that is a restart.
# Do not `crictl rm` it straight away: on v1.35 the kubelet counts the restart from the exited
# container it sees, and if rm wins the race it starts a fresh attempt 0 and RESTARTS stays 0.
""", """,
) )

View File

@ -20,12 +20,19 @@ TRACK = {
"name": "Certified Kubernetes Administrator", "name": "Certified Kubernetes Administrator",
"minutes": 120, "minutes": 120,
"pass_pct": 66, "pass_pct": 66,
# Kubernetes minor of the real exam environment, which picks the kind cluster (shared with CKS).
# LF FAQ / "Important Instructions: CKA and CKAD", checked 2026-10-02: "The CKA environment is
# currently running Kubernetes v1.35". The LF moves each exam on its own schedule.
"k8s": "v1.35",
# the k8s-c1-H / k8s-c2-AC / k8s-c3-CCC contexts some tasks switch to - this track owns them
"exam_contexts": True,
# tasks per domain drawn into a session (weights follow the exam blueprint) # tasks per domain drawn into a session (weights follow the exam blueprint)
"quota": {"ARCH": 5, "WORK": 4, "NET": 4, "STOR": 2, "TSHOOT": 5}, # 20 tasks "quota": {"ARCH": 5, "WORK": 4, "NET": 4, "STOR": 2, "TSHOOT": 5}, # 20 tasks
"quota_hard": {"ARCH": 6, "WORK": 5, "NET": 5, "STOR": 3, "TSHOOT": 6}, # 25 tasks, hard ones preferred "quota_hard": {"ARCH": 6, "WORK": 5, "NET": 5, "STOR": 3, "TSHOOT": 6}, # 25 tasks, hard ones preferred
# fixed simulator sets (./exam sim c|d) - excluded from random sampling. Sets A and B were # fixed simulator sets (./exam sim c|d) - excluded from random sampling. Sets A and B were
# retired on 2026-09-22; their last (fixed, verified) versions are in git history. # retired on 2026-09-22; their last (fixed, verified) versions are in git history.
"sets": {"c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"}, "sets": {"c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"},
# CKA_Curriculum_v1.35.pdf (cncf/curriculum) - unchanged since the February 2025 revision
"domains": { "domains": {
"ARCH": "Cluster Architecture, Installation & Configuration (25%)", "ARCH": "Cluster Architecture, Installation & Configuration (25%)",
"WORK": "Workloads & Scheduling (15%)", "WORK": "Workloads & Scheduling (15%)",
@ -82,11 +89,11 @@ Details you need:
- "ssh to the control-plane node" is: `{SHELL_CP}` - "ssh to the control-plane node" is: `{SHELL_CP}`
- `etcdctl` and `etcdutl` are installed on that node, in `/usr/local/bin` - `etcdctl` and `etcdutl` are installed on that node, in `/usr/local/bin`
Verify the snapshot with `etcdutl snapshot status` (or `etcdctl snapshot status`). Verify the snapshot with `etcdutl snapshot status` (etcd 3.6, which v1.35 runs, removed `etcdctl snapshot status`).
""", """,
"setup": r""" "setup": r"""
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/etcd-snapshot.db' nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/etcd-snapshot.db'
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with ./etcd-tools.sh, then re-seed"; exit 1; } ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with: CKA_CP=$CP ./etcd-tools.sh (or ./etcd-tools.sh cka), then re-seed"; exit 1; }
""", """,
"checks": [ "checks": [
C("/opt/cka/etcd-snapshot.db exists on the control-plane node", 3, r"""nx "$CP" 'test -f /opt/cka/etcd-snapshot.db'"""), C("/opt/cka/etcd-snapshot.db exists on the control-plane node", 3, r"""nx "$CP" 'test -f /opt/cka/etcd-snapshot.db'"""),

View File

@ -48,7 +48,7 @@ etcdutl snapshot status /opt/cka/etcd-snapshot.db -w table
# /var/lib/etcd instead, which is a hostPath, and move it on the node: # /var/lib/etcd instead, which is a hostPath, and move it on the node:
# kubectl -n kube-system exec etcd-{CP} -- etcdctl ... snapshot save /var/lib/etcd/snap.db # kubectl -n kube-system exec etcd-{CP} -- etcdctl ... snapshot save /var/lib/etcd/snap.db
# then on the node: mv /var/lib/etcd/snap.db /opt/cka/etcd-snapshot.db # then on the node: mv /var/lib/etcd/snap.db /opt/cka/etcd-snapshot.db
# ./etcd-tools.sh installs etcdctl/etcdutl on the control-plane node (setup.sh already does). # ./etcd-tools.sh cka installs etcdctl/etcdutl on the control-plane node (setup.sh already does).
""" """
S[3] = """# Step 1: Create the overlay directory structure S[3] = """# Step 1: Create the overlay directory structure
@ -141,7 +141,7 @@ EOF
S[6] = """# Part A: Check certificate expiration on control plane S[6] = """# Part A: Check certificate expiration on control plane
# {WS} is a path on your own machine, so run the remote check and redirect its # {WS} is a path on your own machine, so run the remote check and redirect its
# output locally - don't try to write {WS} from inside the node's own shell. # output locally - don't try to write {WS} from inside the node's own shell.
podman exec cka-sim-control-plane kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q06/apiserver-expiry.txt podman exec {CP} kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q06/apiserver-expiry.txt
# Part B: Issue and approve CertificateSigningRequest (CSR) for user Jane # Part B: Issue and approve CertificateSigningRequest (CSR) for user Jane
# Step 3: Generate private key and CSR # Step 3: Generate private key and CSR
@ -227,10 +227,10 @@ EOF
S[40] = """# Generate a new kubeadm join token with the specified TTL and print-join-command, S[40] = """# Generate a new kubeadm join token with the specified TTL and print-join-command,
# writing the output straight into the required workspace file (run from the host, not the node, # writing the output straight into the required workspace file (run from the host, not the node,
# since {WS} is a path on your machine) # since {WS} is a path on your machine)
podman exec cka-sim-control-plane kubeadm token create --ttl %{ttl} --print-join-command > {WS}/q40/join.sh podman exec {CP} kubeadm token create --ttl %{ttl} --print-join-command > {WS}/q40/join.sh
# Verify active tokens # Verify active tokens
podman exec cka-sim-control-plane kubeadm token list podman exec {CP} kubeadm token list
""" """
S[41] = """# Step 1: Check Kubernetes Server version (API server) S[41] = """# Step 1: Check Kubernetes Server version (API server)
@ -448,7 +448,7 @@ exit
# Step 5: Back on your own machine now - {WS} is a path on your machine, not the node, # Step 5: Back on your own machine now - {WS} is a path on your machine, not the node,
# so capture the expiry report from out here and redirect it into the required file # so capture the expiry report from out here and redirect it into the required file
podman exec cka-sim-control-plane kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q65/expiry.txt podman exec {CP} kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q65/expiry.txt
""" """
S[67] = """# Step 1: On the control-plane node, create a fresh bootstrap token S[67] = """# Step 1: On the control-plane node, create a fresh bootstrap token

View File

@ -62,8 +62,8 @@ podman save --format oci-archive -o {WS}/q13/%{img}-v2.tar localhost/%{img}:v2
podman run --rm localhost/%{img}:v2 > {WS}/q13/run.txt # %{greet} from %{img} v2 podman run --rm localhost/%{img}:v2 > {WS}/q13/run.txt # %{greet} from %{img} v2
# `podman save` writes docker-archive unless told otherwise; "OCI format" means # `podman save` writes docker-archive unless told otherwise; "OCI format" means
# --format oci-archive (an index.json + oci-layout + blobs/ tarball). With docker: # --format oci-archive (an index.json + oci-layout + blobs/ tarball). With docker:
# docker build -t ... . && docker save ... (docker-archive only) - on the exam, read which tool # docker build -t ... . && docker save -o x.tar ... (Docker Engine 25+ writes an OCI layout
# and which format the task names. `--env`/`--build-arg` at build time would not change the # alongside the legacy manifest.json) - on the exam, read which tool and which format the task names. `--env`/`--build-arg` at build time would not change the
# Dockerfile, which is what "change the Dockerfile" asks for. # Dockerfile, which is what "change the Dockerfile" asks for.
""", """,
}, },
@ -851,7 +851,8 @@ eq "$(j -n %{ns} deploy %{dep} "$e.name}")" %{cm} && eq "$(j -n %{ns} deploy %{d
], ],
"solution": """ "solution": """
k -n %{ns} get pod # CreateContainerConfigError k -n %{ns} get pod # CreateContainerConfigError
k -n %{ns} describe pod -l app=%{dep} | grep -A3 Events # couldn't find key log-level in ConfigMap %{ns}/%{cm} k -n %{ns} events --types=Warning # couldn't find key log-level in ConfigMap %{ns}/%{cm}
# (kubectl 1.36+ describe drops Events when a selector matches several Pods - add --show-events)
k -n %{ns} get cm %{cm} -o yaml # the key is log_level (underscore) k -n %{ns} get cm %{cm} -o yaml # the key is log_level (underscore)
k -n %{ns} edit deploy %{dep} # key: log-level -> key: log_level k -n %{ns} edit deploy %{dep} # key: log-level -> key: log_level
k -n %{ns} get pod # now RunContainerError / CrashLoopBackOff k -n %{ns} get pod # now RunContainerError / CrashLoopBackOff

View File

@ -327,7 +327,7 @@ d = str(last.get("description", "")).lower()
sys.exit(0 if last.get("status") == "deployed" and last.get("chart") == "buoy-0.1.0" and "rollback to 1" in d else 1)'"""), sys.exit(0 if last.get("status") == "deployed" and last.get("chart") == "buoy-0.1.0" and "rollback to 1" in d else 1)'"""),
], ],
solution=""" solution="""
helm -n mekong list -a # Helm 3 hides failed releases without -a (Helm 4 lists them anyway) helm -n mekong list --failed # buoy-north (plain `list` shows it too; Helm 4 has no -a)
echo buoy-north > {WS}/q43/failed.txt echo buoy-north > {WS}/q43/failed.txt
helm -n mekong uninstall buoy-north helm -n mekong uninstall buoy-north
helm -n mekong get values buoy-west # region: west - the value to keep helm -n mekong get values buoy-west # region: west - the value to keep
@ -819,6 +819,8 @@ Deployment `harbor-proxy` in Namespace `niger` takes its whole nginx configurati
setup=NS + r""" setup=NS + r"""
mkns niger; rm -rf "$WS/q51"; mkdir -p "$WS/q51" mkns niger; rm -rf "$WS/q51"; mkdir -p "$WS/q51"
$K -n niger delete deploy harbor-proxy --ignore-not-found --wait=true >/dev/null $K -n niger delete deploy harbor-proxy --ignore-not-found --wait=true >/dev/null
# a test Pod left by an interrupted earlier attempt would make `kubectl run tmp` fail "AlreadyExists"
$K -n niger delete pod tmp --ignore-not-found --wait=false >/dev/null
$K apply -f - >/dev/null <<'EOF' $K apply -f - >/dev/null <<'EOF'
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap

View File

@ -11,6 +11,12 @@ TRACK = {
"name": "Certified Kubernetes Application Developer", "name": "Certified Kubernetes Application Developer",
"minutes": 120, "minutes": 120,
"pass_pct": 66, "pass_pct": 66,
# Kubernetes minor of the real exam environment, which picks the kind cluster. LF FAQ /
# "Important Instructions: CKA and CKAD", checked 2026-10-02: "The CKAD environment is currently
# running Kubernetes v1.37" (it went there from v1.35 at the end of September 2026).
"k8s": "v1.37",
# CKAD_Curriculum_v1.37.pdf (cncf/curriculum) - the same document as v1.35, so these and every
# competency the bank covers are unchanged by the version bump
"domains": { "domains": {
"DESIGN": "Application Design and Build (20%)", "DESIGN": "Application Design and Build (20%)",
"DEPLOY": "Application Deployment (20%)", "DEPLOY": "Application Deployment (20%)",

View File

@ -13,6 +13,11 @@ TRACK = {
"name": "Certified Kubernetes Security Specialist", "name": "Certified Kubernetes Security Specialist",
"minutes": 120, "minutes": 120,
"pass_pct": 67, "pass_pct": 67,
# Kubernetes minor of the real exam environment, which picks the kind cluster (shared with CKA).
# LF "Important Instructions: CKS", checked 2026-10-02: "The CKS environment is currently running
# Kubernetes v1.35". (cncf/curriculum still names the PDF "CKS_Curriculum v1.34".)
"k8s": "v1.35",
# CKS curriculum, unchanged since the April 2025 revision
"domains": { "domains": {
"SETUP": "Cluster Setup (15%)", "SETUP": "Cluster Setup (15%)",
"HARDEN": "Cluster Hardening (15%)", "HARDEN": "Cluster Hardening (15%)",
@ -380,12 +385,13 @@ Create Pod `%{pod}` in Namespace `%{ns}` (image `busybox:1.36`, `sleep 86400`) t
- uses ServiceAccount `%{pod}-sa` (create it), - uses ServiceAccount `%{pod}-sa` (create it),
- does **not** get an API token mounted, set on the **Pod**, not the ServiceAccount, - does **not** get an API token mounted, set on the **Pod**, not the ServiceAccount,
- runs with `hostNetwork`, `hostPID` and `hostIPC` all explicitly disabled, - does not use the host network, PID or IPC namespaces (set `hostNetwork`, `hostPID` and
`hostIPC` to `false` - the API stores `false` as unset, so the check is that none is `true`),
- has both CPU and memory requests and limits set (any sane values). - has both CPU and memory requests and limits set (any sane values).
""", """,
"setup": NS + r""" "setup": NS + r"""
mkns %{ns} mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null
$K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null $K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null
""", """,
"checks": [ "checks": [
@ -393,9 +399,12 @@ $K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null
C("token not mounted, set on the pod spec", 3, r""" C("token not mounted, set on the pod spec", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.spec.automountServiceAccountToken}')" false \ eq "$(j -n %{ns} pod %{pod} '{.spec.automountServiceAccountToken}')" false \
&& ! $K -n %{ns} exec %{pod} -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""), && ! $K -n %{ns} exec %{pod} -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""),
C("host namespaces explicitly disabled", 3, r""" # hostNetwork/hostPID/hostIPC are omitempty booleans: an explicit false is never stored, so the
# most a check can prove is that the Pod exists and none of them is true
C("no host network, PID or IPC namespace", 3, r"""
p="$K -n %{ns} get pod %{pod}" p="$K -n %{ns} get pod %{pod}"
eq "$($p -o jsonpath='{.spec.hostNetwork}' 2>/dev/null)" false && eq "$($p -o jsonpath='{.spec.hostPID}')" false && eq "$($p -o jsonpath='{.spec.hostIPC}')" false"""), $p >/dev/null 2>&1 || exit 1
for f in hostNetwork hostPID hostIPC; do [ "$($p -o jsonpath="{.spec.$f}")" != true ] || exit 1; done"""),
C("requests and limits set for cpu and memory", 2, r""" C("requests and limits set for cpu and memory", 2, r"""
c='{.spec.containers[0].resources' c='{.spec.containers[0].resources'
for f in requests.cpu requests.memory limits.cpu limits.memory; do [ -n "$(j -n %{ns} pod %{pod} "$c.$f}")" ] || exit 1; done"""), for f in requests.cpu requests.memory limits.cpu limits.memory; do [ -n "$(j -n %{ns} pod %{pod} "$c.$f}")" ] || exit 1; done"""),
@ -508,7 +517,14 @@ On the control-plane node:
""", """,
"setup": r""" "setup": r"""
rm -rf "$WS/q9"; mkdir -p "$WS/q9" rm -rf "$WS/q9"; mkdir -p "$WS/q9"
nx "$CP" 'sed -i "/--audit-/d" /etc/kubernetes/manifests/kube-apiserver.yaml; rm -f /etc/kubernetes/audit-policy.yaml' || true # Back to "no audit trail" without bricking the API server: a previous answer may have left a hostPath
# volume of type File for the policy, so leave a do-nothing policy in its place (check 1 still fails
# until the candidate writes theirs), drop the old log so it cannot pass check 3, and edit the manifest
# through a temp file outside manifests/ - a sed -i temp file there is loaded as a second static pod.
nx "$CP" 'f=/etc/kubernetes/manifests/kube-apiserver.yaml
if grep -q -- "--audit-" $f; then t=$(mktemp /etc/kubernetes/.q9.XXXXXX) && sed "/--audit-/d" $f > $t && chmod --reference=$f $t && mv -f $t $f; fi
printf "apiVersion: audit.k8s.io/v1\nkind: Policy\nrules:\n- level: None\n" > /etc/kubernetes/audit-policy.yaml
rm -f /var/log/kubernetes/audit*.log' || true
""", """,
"checks": [ "checks": [
C("audit policy file exists and omits RequestReceived", 3, r""" C("audit policy file exists and omits RequestReceived", 3, r"""

View File

@ -180,7 +180,7 @@
<div style="color:var(--muted);font-size:12px;margin:-8px 0 4px">Drill a domain (✓ = drilled at least once):</div> <div style="color:var(--muted);font-size:12px;margin:-8px 0 4px">Drill a domain (✓ = drilled at least once):</div>
<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 style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks, exam contexts.</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="opts" id="lobbyOpts"> <div class="opts" id="lobbyOpts">
<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>
@ -262,7 +262,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; $('#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' : '') : '';
$('#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';
@ -309,7 +309,7 @@
tw.innerHTML = S.ttyd ? `<iframe id="term" src="http://127.0.0.1:${S.ttyd_port}/" title="terminal"></iframe>` : tw.innerHTML = S.ttyd ? `<iframe id="term" src="http://127.0.0.1:${S.ttyd_port}/" title="terminal"></iframe>` :
`<div id="noterm">The terminal pane needs <code>ttyd</code>, which isn't installed.<br><br> `<div id="noterm">The terminal pane needs <code>ttyd</code>, which isn't installed.<br><br>
Install it (<code>dnf install ttyd</code>, <code>pacman -S ttyd</code>, <code>brew install ttyd</code>, or a release from github.com/tsl0922/ttyd) Install it (<code>dnf install ttyd</code>, <code>pacman -S ttyd</code>, <code>brew install ttyd</code>, or a release from github.com/tsl0922/ttyd)
and restart <code>./exam web</code>.<br><br>Until then, work in any terminal with kubectl pointed at the cluster. and restart <code>./exam web</code>.<br><br>Until then, work in any terminal with kubectl pointed at <code>${S.cluster}</code>.
Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`; Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`;
} }
tickClock(); tickClock();
@ -338,7 +338,7 @@
const sess = S.session; const sess = S.session;
const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {}); const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {});
const total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length; const total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length;
$('#examDesc').textContent = `${total} tasks sampled across the 5 domains, ${S.track.minutes} minutes, pass mark ${S.track.pass_pct}%. No feedback until you submit.`; $('#examDesc').textContent = `${total} tasks sampled across the ${Object.keys(S.track.domains).length} domains, ${S.track.minutes} minutes, pass mark ${S.track.pass_pct}%. No feedback until you submit.`;
const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]); const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]);
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d => $('#coverage').innerHTML = Object.keys(S.track.domains).map(d =>
`<span class="pill pick ${drilled[d] ? 'done' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks">${d} ${drilled[d] ? '✓' : '·'}</span>`).join(''); `<span class="pill pick ${drilled[d] ? 'done' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks">${d} ${drilled[d] ? '✓' : '·'}</span>`).join('');
@ -357,8 +357,12 @@
$('#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;
// 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'
+ (S.track.exam_contexts ? ', exam contexts.' : '.');
// each track runs on its own cluster at its exam's Kubernetes version
$('#trackPick').innerHTML = (S.tracks || []).map(t => $('#trackPick').innerHTML = (S.tracks || []).map(t =>
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}">${t.toUpperCase()}</span>`).join(''); `<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}" title="Kubernetes ${(S.track_k8s || {})[t] || '?'}">${t.toUpperCase()} <span style="opacity:.6">${(S.track_k8s || {})[t] || ''}</span></span>`).join('');
$('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => { $('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => {
if (el.dataset.track === S.track_id) return; if (el.dataset.track === S.track_id) return;
S = await api('/api/track', {track: el.dataset.track}); cur = null; load(); S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
@ -414,7 +418,9 @@
async function submit() { async function submit() {
$('#confirm').classList.add('hidden'); $('#confirm').classList.add('hidden');
$('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…'; $('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…';
try { S = await api('/api/end', {}); } finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; } try { S = await api('/api/end', {}); }
catch (e) { alert(e.message); return; } // e.g. the cluster is down - nothing was graded or recorded
finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; }
showResults(); paint(); showResults(); paint();
} }