diff --git a/CKA_EXAM_GUIDE.md b/CKA_EXAM_GUIDE.md index 0e2d468..f7f3b74 100644 --- a/CKA_EXAM_GUIDE.md +++ b/CKA_EXAM_GUIDE.md @@ -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 ` | `--endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=...` | | **Etcd Restore** | `etcdutl snapshot restore ` | `--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`. diff --git a/CKA_SOLUTIONS_EXPLAINED.md b/CKA_SOLUTIONS_EXPLAINED.md index f8f1a2a..9306696 100644 --- a/CKA_SOLUTIONS_EXPLAINED.md +++ b/CKA_SOLUTIONS_EXPLAINED.md @@ -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. --- diff --git a/README.md b/README.md index 06ee6e7..23dbb01 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 `: 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-` (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 '{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. diff --git a/etcd-tools.sh b/etcd-tools.sh index 08e988b..3cb8958 100755 --- a/etcd-tools.sh +++ b/etcd-tools.sh @@ -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= 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 diff --git a/exam b/exam index 3bc97e2..6d4d786 100755 --- a/exam +++ b/exam @@ -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" 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 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; 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 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] (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 ''}{' ' * 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" diff --git a/examrc b/examrc index 7700af8..cf00111 100644 --- a/examrc +++ b/examrc @@ -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 diff --git a/examrc.fish b/examrc.fish index b8ec94f..50d6311 100644 --- a/examrc.fish +++ b/examrc.fish @@ -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 diff --git a/kind-config.yaml b/kind-config.yaml index 2e8b488..adbbd17 100644 --- a/kind-config.yaml +++ b/kind-config.yaml @@ -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 diff --git a/node-tools.sh b/node-tools.sh index 1ad5286..f3b0ea7 100755 --- a/node-tools.sh +++ b/node-tools.sh @@ -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 diff --git a/setup.sh b/setup.sh index 743e0ed..a468a0a 100755 --- a/setup.sh +++ b/setup.sh @@ -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//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" diff --git a/teardown.sh b/teardown.sh index 81cdc45..2a66bd9 100755 --- a/teardown.sh +++ b/teardown.sh @@ -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 diff --git a/tracks/cka/bank_extra.py b/tracks/cka/bank_extra.py index 3f67291..2f471de 100644 --- a/tracks/cka/bank_extra.py +++ b/tracks/cka/bank_extra.py @@ -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 ' diff --git a/tracks/cka/bank_hard.py b/tracks/cka/bank_hard.py index 0e5a50a..c6f6048 100644 --- a/tracks/cka/bank_hard.py +++ b/tracks/cka/bank_hard.py @@ -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) \ diff --git a/tracks/cka/bank_sim_c.py b/tracks/cka/bank_sim_c.py index 2e62cfc..c8955c0 100644 --- a/tracks/cka/bank_sim_c.py +++ b/tracks/cka/bank_sim_c.py @@ -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 --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 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 and never scales. """, ) diff --git a/tracks/cka/bank_sim_d.py b/tracks/cka/bank_sim_d.py index fe73e49..45b762c 100644 --- a/tracks/cka/bank_sim_d.py +++ b/tracks/cka/bank_sim_d.py @@ -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 && crictl rm +crictl stop 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. """, ) diff --git a/tracks/cka/questions.py b/tracks/cka/questions.py index 0e294ff..3ce2ded 100644 --- a/tracks/cka/questions.py +++ b/tracks/cka/questions.py @@ -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'"""), diff --git a/tracks/cka/solutions.py b/tracks/cka/solutions.py index 1291b35..600f44d 100644 --- a/tracks/cka/solutions.py +++ b/tracks/cka/solutions.py @@ -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 diff --git a/tracks/ckad/bank_extra.py b/tracks/ckad/bank_extra.py index 7f4f5f3..e5e709c 100644 --- a/tracks/ckad/bank_extra.py +++ b/tracks/ckad/bank_extra.py @@ -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 diff --git a/tracks/ckad/bank_sim_a.py b/tracks/ckad/bank_sim_a.py index 737a96a..de30dea 100644 --- a/tracks/ckad/bank_sim_a.py +++ b/tracks/ckad/bank_sim_a.py @@ -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 diff --git a/tracks/ckad/questions.py b/tracks/ckad/questions.py index 462d06d..35dd7f3 100644 --- a/tracks/ckad/questions.py +++ b/tracks/ckad/questions.py @@ -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%)", diff --git a/tracks/cks/questions.py b/tracks/cks/questions.py index 5654714..ffaf670 100644 --- a/tracks/cks/questions.py +++ b/tracks/cks/questions.py @@ -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""" diff --git a/web/index.html b/web/index.html index 8ecdbca..0459503 100644 --- a/web/index.html +++ b/web/index.html @@ -180,7 +180,7 @@
Drill a domain (✓ = drilled at least once):
+ — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks.
@@ -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 ? `` : `
The terminal pane needs ttyd, which isn't installed.

Install it (dnf install ttyd, pacman -S ttyd, brew install ttyd, or a release from github.com/tsl0922/ttyd) - and restart ./exam web.

Until then, work in any terminal with kubectl pointed at the cluster. + and restart ./exam web.

Until then, work in any terminal with kubectl pointed at ${S.cluster}. Task files go under ${S.workspace}/, relative to the cka-sim directory.
`; } 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 => `${d} ${drilled[d] ? '✓' : '·'}`).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 => - `${t.toUpperCase()}`).join(''); + `${t.toUpperCase()} ${(S.track_k8s || {})[t] || ''}`).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(); }