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.
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
@ -52,7 +54,7 @@ EOF
| **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 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
`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.
---
## 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/`
- GitHub Kubernetes Organization: `https://github.com/kubernetes/`
- Cheatsheet: `https://kubernetes.io/docs/reference/kubectl/cheatsheet/`
- Helm Documentation: `https://helm.sh/docs`
- Gateway API Documentation (CKA only): `https://gateway-api.sigs.k8s.io`
### 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`.

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).
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
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 -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
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
least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (scaffolded — same format, add
questions when you get there).
least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (a
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
@ -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
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` /
`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.
`k8s-c2-AC` / `k8s-c3-CCC` with different default namespaces — each task says which to use
(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
@ -38,15 +40,47 @@ of `ssh <node>` here; use the Vagrant/ssh mode below if you want a real ssh sess
## Requirements
podman, kind, kubectl, python3, **ttyd** (for the terminal pane in the browser UI).
~4 GB RAM for the 4-node cluster. Rootless podman needs cgroup v2; otherwise `sudo -E ./setup.sh`.
podman, kind (v0.33.0 publishes both node images used here), kubectl, python3, **ttyd** (for the
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`.
## 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
```
./setup.sh # create the kind cluster, preload images (idempotent)
./exam web # open http://127.0.0.1:8080 — start an exam or drill from there
./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
```
Both the built-in terminal and your own shell can have the exam aliases:
@ -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
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
at the cluster (`--shell fish` if you'd rather practise in fish; the real exam is bash).
"Drill what I missed". The terminal pane is a real `bash` via ttyd with kubectl pointed at the
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:
@ -74,13 +108,15 @@ The same state is available from the terminal if you prefer:
./exam list | show N | timer | status # during
./exam end # submit
./exam weak | history # afterwards
./exam --track ckad web # other tracks (or CKA_TRACK=ckad)
./teardown.sh # delete cluster + state (history kept)
./exam --track ckad web # other tracks and their clusters (or CKA_TRACK=ckad)
./exam clusters # Kubernetes version and cluster per track
./teardown.sh [cka|ckad|cks|legacy] # delete clusters + state (history kept; default all)
```
## 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
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,
@ -118,7 +154,9 @@ but left out of every statistic.
## 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
@ -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.
`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
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
@ -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'`,
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
session draws. For CKS, fill in `QUESTIONS` in the
existing placeholder track; `TRACK` already carries the domain table and pass mark.
session draws, `TRACK["k8s"]` the Kubernetes minor (and so the cluster) the track runs on, and
`TRACK["exam_contexts"]` whether it owns the `k8s-c*` contexts. For CKS, add to `QUESTIONS` in
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
# 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.
# ./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,
# 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
cd "$(dirname "$0")"
@ -67,6 +69,6 @@ fi
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo 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\n' "$NODE_SCRIPT" | "$RT" exec -i "$CP" sh -s

243
exam
View File

@ -38,6 +38,23 @@ PASS_PCT = 0
STATE_FILE = ""
HISTORY_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):
@ -45,13 +62,14 @@ def load_track(track_id):
try:
mod = importlib.import_module(f"tracks.{track_id}.questions")
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)
track, questions = mod.TRACK, list(mod.QUESTIONS)
for q in questions:
q["weight"] = sum(c["pts"] for c in q["checks"])
q.setdefault("difficulty", "normal")
q.setdefault("set", None)
cluster = kind_cluster(track["k8s"])
g = globals()
g.update(
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"),
HISTORY_FILE=os.path.join(ROOT, f"history-{track_id}.jsonl"),
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
NAMESPACES_TO_SETTLE = ["apps", "netpol", "svc", "ingress-app", "dns-check", "storage"]
# --- cluster targeting (override with env vars to use a non-kind cluster) ---
CONTEXT = os.environ.get("CKA_CONTEXT", "kind-cka-sim")
# --- cluster targeting: CONTEXT and NODES follow the track (load_track); env vars override ---
def _default_access():
try:
return open(os.path.join(STATE_DIR, "runtime")).read().strip() # written by setup.sh
except OSError:
return "podman"
NODE_ACCESS = os.environ.get("CKA_NODE_ACCESS", _default_access()) # podman | docker | ssh | vagrant
NODES = {
"CP": os.environ.get("CKA_CP", "cka-sim-control-plane"),
"W1": os.environ.get("CKA_W1", "cka-sim-worker"),
"W2": os.environ.get("CKA_W2", "cka-sim-worker2"),
"W3": os.environ.get("CKA_W3", "cka-sim-worker3"),
}
# kubectl matching each cluster's server version, fetched by setup.sh: .state/kubectl/v1.35/kubectl.
# .state/kubectl/current links to the one for the cluster of the latest session; examrc puts it
# first on PATH, so your terminals switch kubectl version together with the cluster.
KUBECTL_DIR = os.path.join(STATE_DIR, "kubectl")
def shell_hint(node):
return {"podman": f"podman exec -it {node} bash",
"docker": f"docker exec -it {node} bash",
@ -103,13 +125,12 @@ def shell_hint(node):
PRELUDE = r"""
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"
# 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; }
eq() { [ "$1" = "$2" ]; }
CP="${CKA_CP:-cka-sim-control-plane}"; W1="${CKA_W1:-cka-sim-worker}"
W2="${CKA_W2:-cka-sim-worker2}"; W3="${CKA_W3:-cka-sim-worker3}"
CP="${CKA_CP:?}"; W1="${CKA_W1:?}"; W2="${CKA_W2:?}"; W3="${CKA_W3:?}" # run() exports the track's nodes
# nx NODE 'command' -> run a shell command on a node (stdin is forwarded)
nx() {
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.
ensure_gateway_crds() {
$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 \
&& $K apply -f /tmp/gwapi.yaml >/dev/null 2>&1 \
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml \
| $K apply -f - >/dev/null 2>&1 \
&& $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
}
# 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.
# 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() {
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 2>&1 || return 1
curl -sfL --max-time 60 "$METRICS_SERVER_URL" | $K apply -f - >/dev/null 2>&1 || return 1
fi
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 \
@ -168,8 +191,13 @@ def red(s): return c("31", s)
def yellow(s): return c("33", s)
def bold(s): return c("1", 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):
print(red(msg)); sys.exit(code)
print(red(msg))
e = Die(code); e.msg = msg
raise e
# ------------------------------------------------------------- state ----
def load_state() -> dict[str, Any] | None:
@ -220,6 +248,17 @@ def require_started() -> dict[str, Any]:
assert st is not None
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]]:
if not st:
return list(QUESTIONS)
@ -269,19 +308,34 @@ def make_params(q, rng):
return fn(rng) if fn else {}
# -------------------------------------------------------------- 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):
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,
timeout=timeout, stdin=subprocess.DEVNULL)
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:
return subprocess.run(["kubectl", "--context", CONTEXT, "get", "--raw=/version"],
capture_output=True, text=True, timeout=15).returncode == 0
p = subprocess.run(["kubectl", "--context", CONTEXT, "get", "--raw=/version"],
capture_output=True, text=True, timeout=15)
return json.loads(p.stdout).get("gitVersion") if p.returncode == 0 else None
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):
"""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):
earned, total, pct = score(st)
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,
"time_used": int(st["ended_at"] - st["started_at"] - paused_total(st)),
"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)
if rc != 0:
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"
" 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")):
if not shutil.which(str(tool)):
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
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.
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}}"],
capture_output=True, text=True).stdout.strip()
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"'
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
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
# 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:
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()
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.")
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():
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:
die(f"Track '{TRACK_ID}' has no questions yet.")
preflight()
@ -841,17 +979,19 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
rng = random.Random()
params_by_id = {str(q["id"]): make_params(q, rng) for q in qs}
setup_contexts(hard, params_by_id)
use_kubectl()
restore_baseline(log)
log(bold(f"Seeding {len(qs)} scenario(s){' [HARD]' if hard else ''}..."))
seed(qs, log, params_by_id)
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,
"results": {}, "flags": [], "notes": ""}
"k8s": server_version(), "results": {}, "flags": [], "notes": ""}
save_state(st)
log("")
log(bold("=" * 64))
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(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}%")
@ -1105,6 +1245,9 @@ def cmd_end(args):
st = require_started()
if st.get("ended_at"):
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:
print(yellow("Note: time expired before submission."))
entry = finish_session(st)
@ -1131,7 +1274,7 @@ def cmd_abort(args):
setup_contexts(False, {})
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(" './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):
agg = stats(); hist = load_history()
@ -1239,9 +1382,9 @@ class Api(http.server.BaseHTTPRequestHandler):
def work():
try:
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":
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:
if body.get("domain") in DOMAINS:
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))
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,
untimed_session=bool(body.get("study")))
except SystemExit:
Api.seed_error = "\n".join(l for l in Api.log_lines if l.strip()) or "start failed - see the terminal running ./exam web"
untimed_session=bool(body.get("study")), other_ok=False)
except SystemExit as e:
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
Api.seed_error = f"{type(e).__name__}: {e}"
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)
elif p.path == "/api/end":
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)
elif p.path == "/api/reset":
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}
for q in qs],
"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()),
"seeding": Api.seeding, "seed_error": Api.seed_error,
"session": None,
@ -1384,11 +1531,27 @@ def cmd_web(args):
ttyd.terminate()
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):
print(__doc__ + f"\nversion {VERSION}")
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
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
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,
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
show N display task N
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
history every attempt, scores, and per-domain trend
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,
"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__":
cmd = ARGV[0] if ARGV else "help"

7
examrc
View File

@ -2,6 +2,13 @@
# and safe to source yourself: source ./examrc
[ -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
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

View File

@ -1,4 +1,7 @@
# 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
set -gx KUBE_EDITOR (test -n "$KUBE_EDITOR"; and echo $KUBE_EDITOR; or echo vi)
# 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
apiVersion: kind.x-k8s.io/v1alpha4
name: cka-sim
nodes:
- role: control-plane
- role: worker

View File

@ -1,11 +1,13 @@
#!/usr/bin/env bash
# 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.
# ./node-tools.sh [TRACK] the nodes of TRACK's cluster (default: the track ./exam defaults to)
set -euo pipefail
cd "$(dirname "$0")"
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo 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"
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

238
setup.sh
View File

@ -1,5 +1,12 @@
#!/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.
set -euo pipefail
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 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
export KIND_EXPERIMENTAL_PROVIDER=podman
podman info >/dev/null 2>&1 || { echo "ERROR: podman is not working (try: podman info)."; exit 1; }
@ -31,71 +52,178 @@ else
docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon is not running/reachable."; exit 1; }
fi
if kind get clusters 2>/dev/null | grep -qx cka-sim; then
echo "==> kind cluster 'cka-sim' already exists, reusing it."
else
echo "==> Creating kind cluster 'cka-sim' on $RT (1 control-plane + 3 workers)..."
kind create cluster --config kind-config.yaml ${KIND_NODE_IMAGE:+--image "$KIND_NODE_IMAGE"} --wait 180s
fi
# a session in progress keeps its kubectl context and kubectl version (kind create switches the context)
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)
kubectl config use-context kind-cka-sim >/dev/null
echo "==> Waiting for nodes..."
kubectl wait --for=condition=Ready nodes --all --timeout=180s
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
# provider the cluster uses, so it can't see images that only exist in podman's
# store. Save to a tar and use `kind load image-archive` instead, which is
# runtime-agnostic.
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
tarfile="$(mktemp)"
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
echo " loaded $img"
else
echo " warn: could not load $img"
fi
rm -f "$tarfile"
else
echo " warn: could not pull $img (tasks will pull at runtime)"
fi
# 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
echo "==> Making sure the exam workdir exists on the control-plane node..."
"$RT" exec cka-sim-control-plane mkdir -p /opt/cka
# 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.
if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then
echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..."
for n in $(kind get nodes --name cka-sim); do
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then
echo " $n already has vim"
continue
# 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
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
"$RT" exec "$n" sh -c 'printf "set tabstop=2\nset expandtab\nset shiftwidth=2\n" > /root/.vimrc'
echo " $n ok"
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
echo "==> Creating kind cluster '$cluster' (Kubernetes $k8s) on $RT (1 control-plane + 3 workers)..."
create "$cluster" "$image"
fi
echo "==> Waiting for nodes..."
# `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)..."
# `kind load docker-image` shells out to the docker CLI internally no matter which
# provider the cluster uses, so it can't see images that only exist in podman's
# store. Save to a tar and use `kind load image-archive` instead, which is
# runtime-agnostic.
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
tarfile="$(mktemp)"
if "$RT" save -o "$tarfile" "docker.io/library/$img" >/dev/null 2>&1 \
&& kind load image-archive --name "$cluster" "$tarfile" >/dev/null 2>&1; then
echo " loaded $img"
else
echo " warn: could not load $img"
fi
rm -f "$tarfile"
else
echo " warn: could not install tools on $n (no outbound network?). Fall back to:"
echo " $RT cp $n:/path/file /tmp/f && \$EDITOR /tmp/f && $RT cp /tmp/f $n:/path/file"
echo " warn: could not pull $img (tasks will pull at runtime)"
fi
done
fi
# The exam expects etcdctl/etcdutl on the control-plane node, like the real one. They cannot be
# copied out of the running etcd pod (that image is distroless), so install them properly.
echo "==> Installing etcdctl/etcdutl on the control-plane node..."
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"
echo "==> Making sure the exam workdir exists on the control-plane node..."
"$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
# install them once here. Skip with CKA_SKIP_NODE_TOOLS=1.
if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then
echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..."
for n in $(kind get nodes --name "$cluster"); do
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then
echo " $n 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
"$RT" exec "$n" sh -c 'printf "set tabstop=2\nset expandtab\nset shiftwidth=2\n" > /root/.vimrc'
echo " $n ok"
else
echo " warn: could not install tools on $n (no outbound network?). Fall back to:"
echo " $RT cp $n:/path/file /tmp/f && \$EDITOR /tmp/f && $RT cp /tmp/f $n:/path/file"
fi
done
fi
# The exam expects etcdctl/etcdutl on the control-plane node, like the real one. They cannot be
# copied out of the running etcd pod (that image is distroless), so install them properly.
echo "==> Installing etcdctl/etcdutl on the control-plane node..."
printf ' '
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
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
echo
echo "Cluster ready. Nodes:"
kubectl get nodes -o wide
echo
echo "Next: ./exam web (browser UI) or ./exam drill (5 tasks from your least-drilled domain)"
echo "Next: ./exam --track ${tracks[0]} web (browser UI) or ./exam --track ${tracks[0]} drill (5 tasks from your least-drilled domain)"
echo " ./exam clusters which cluster and Kubernetes version each track uses"

View File

@ -1,11 +1,47 @@
#!/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
cd "$(dirname "$0")"
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
kind delete cluster --name cka-sim || true
# 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.
rm -rf .state workspace
echo "cka-sim cluster and local state removed."
# 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
# nothing there is tracked (see .gitignore). Anything you want to keep, copy out first.
rm -rf .state workspace
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} delete hpa %{dep} --ignore-not-found >/dev/null
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
# 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 '

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"""
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 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) \

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 get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion --no-headers > {WS}/q80/kubelets.txt
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 apply v1.34.1 -y
kubeadm upgrade apply v1.36.5 -y
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 restart kubelet
kubectl uncordon <control-plane>
EOF
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
# `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
@ -1068,8 +1071,8 @@ spec:
- {type: Pods, value: 2, periodSeconds: 60}
EOF
k -n route-planner get hpa route-planner-hpa
# `kubectl autoscale` only emits CPU targets and no behavior block, so memory + behavior means
# writing the v2 YAML by hand. Utilisation is a percentage OF THE REQUEST, so the Deployment must
# `kubectl autoscale` can set a memory target (--memory=70%) but has no flag for a behavior block,
# 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.
""",
)

View File

@ -237,8 +237,8 @@ task(
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
`{WS}/q90/pod-deleted.log`.
3. On node `{W2}`, stop **and remove** the kube-proxy **container** with `crictl` — leave the Pod
object alone. Write the events this caused to `{WS}/q90/container-killed.log`.
3. On node `{W2}`, stop the kube-proxy **container** with `crictl stop` — leave the Pod object
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
`{WS}/q90/restarts.txt`.
@ -283,7 +283,7 @@ k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-pr
{SHELL_W2}
crictl ps --name kube-proxy # container id
crictl stop <id> && crictl rm <id>
crictl stop <id>
exit
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
@ -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
# 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.
# 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",
"minutes": 120,
"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)
"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
# 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.
"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": {
"ARCH": "Cluster Architecture, Installation & Configuration (25%)",
"WORK": "Workloads & Scheduling (15%)",
@ -82,11 +89,11 @@ Details you need:
- "ssh to the control-plane node" is: `{SHELL_CP}`
- `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"""
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": [
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:
# 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
# ./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
@ -141,7 +141,7 @@ EOF
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
# 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
# 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,
# writing the output straight into the required workspace file (run from the host, not the node,
# 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
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)
@ -448,7 +448,7 @@ exit
# 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
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

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 save` writes docker-archive unless told otherwise; "OCI format" means
# --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
# and which format the task names. `--env`/`--build-arg` at build time would not change the
# docker build -t ... . && docker save -o x.tar ... (Docker Engine 25+ writes an OCI layout
# 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.
""",
},
@ -851,7 +851,8 @@ eq "$(j -n %{ns} deploy %{dep} "$e.name}")" %{cm} && eq "$(j -n %{ns} deploy %{d
],
"solution": """
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} edit deploy %{dep} # key: log-level -> key: log_level
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)'"""),
],
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
helm -n mekong uninstall buoy-north
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"""
mkns niger; rm -rf "$WS/q51"; mkdir -p "$WS/q51"
$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'
apiVersion: v1
kind: ConfigMap

View File

@ -11,6 +11,12 @@ TRACK = {
"name": "Certified Kubernetes Application Developer",
"minutes": 120,
"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": {
"DESIGN": "Application Design and Build (20%)",
"DEPLOY": "Application Deployment (20%)",

View File

@ -13,6 +13,11 @@ TRACK = {
"name": "Certified Kubernetes Security Specialist",
"minutes": 120,
"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": {
"SETUP": "Cluster Setup (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),
- 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).
""",
"setup": NS + r"""
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
""",
"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"""
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"""),
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}"
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='{.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"""),
@ -508,7 +517,14 @@ On the control-plane node:
""",
"setup": r"""
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": [
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 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>
<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">
<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>
@ -262,7 +262,7 @@
function paint() {
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' : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
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>` :
`<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)
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>`;
}
tickClock();
@ -338,7 +338,7 @@
const sess = S.session;
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;
$('#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]);
$('#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('');
@ -357,8 +357,12 @@
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
$('#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 =>
`<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 () => {
if (el.dataset.track === S.track_id) return;
S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
@ -414,7 +418,9 @@
async function submit() {
$('#confirm').classList.add('hidden');
$('#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();
}