From df04c56533c2522001703bad818b7a518dcceb49 Mon Sep 17 00:00:00 2001 From: vumast3r Date: Mon, 14 Sep 2026 19:43:15 -0600 Subject: [PATCH] fix: create exam contexts when needed, widen baseline reset, bind ttyd to loopback Three fixes from an audit of the repo: 1. setup_contexts() deleted all three k8s-c?-? contexts and recreated none unless the session was started with --hard. But './exam drill ' and './exam drill weak' don't filter by difficulty, so hard tasks land in hard=False sessions - and 24 of them open with "kubectl config use-context ...", which then fails outright. Q121 was unpassable that way: it says to deploy into the context's namespace without -n, and its check requires that namespace, so the context was the only way to learn the name. Contexts are now created whenever a task in the session references one. Also fixes how k8s-c2-AC's namespace was chosen: it took the first ns in the whole session, which is arbitrary when several tasks share a context. A context has one default namespace, so it belongs to the task that actually reads it - Q121, now marked "ctx_ns": True. 2. restore_baseline() cleared taint keys dedicated/workload/team, but Q107 draws from pool/lane and Q307 pins maintenance=inprogress, and nothing uncordoned a node. A leaked cordon or taint silently fails every later task that counts pods per node (Q26/Q211 DaemonSet coverage, Q212's spread) - W1 had been cordoned and tainted across sessions for two days. Clears all six keys now and uncordons all three workers. 3. ttyd was launched with -W (writable) and no interface binding, so it listened on 0.0.0.0 while the API server correctly bound localhost. That exposed an unauthenticated root-equivalent shell with the cluster kubeconfig to the whole network. Pinned to loopback, overridable via CKA_TTYD_INTERFACE. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Fqcio9TXkEXH35UHebTgoL --- exam | 59 ++++++++++++++++++++++++++++++++++------- tracks/cka/bank_hard.py | 3 +++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/exam b/exam index 053bfae..add7986 100755 --- a/exam +++ b/exam @@ -499,22 +499,47 @@ def preflight(): HARD_CONTEXTS = {"k8s-c1-H": "default", "k8s-c2-AC": "%{ns}", "k8s-c3-CCC": "kube-system"} +def context_namespace(ctx, template, params_by_id): + """Default namespace for one exam context. + + Several tasks can name the same context while working in different namespaces, but a + context has only one default namespace - so it belongs to whichever task actually reads it + ("ctx_ns": True, i.e. the task says to work in the context's namespace without -n). Every + other task states its namespace in its own text, so the value does not matter to them. + """ + if "%{ns}" not in template: + return template + def ns_of(pred): + return [p["ns"] for i, p in params_by_id.items() + if p.get("ctx") == ctx and p.get("ns") and pred(QBYID.get(int(i)) or {})] + return (ns_of(lambda q: q.get("ctx_ns")) or ns_of(lambda q: True) + or [p["ns"] for p in params_by_id.values() if p.get("ns")] or ["default"])[0] + def setup_contexts(hard, params_by_id): - """Hard mode: several contexts on the same cluster with different default namespaces, like the real exam.""" + """Exam-style contexts on the same cluster with different default namespaces. + + Created whenever a task in the session asks for one - 24 tasks open with + `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. + """ 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}}"], capture_output=True, text=True).stdout.strip() for name in HARD_CONTEXTS: subprocess.run(["kubectl", "config", "delete-context", name], capture_output=True) - if not hard: + wanted = set(HARD_CONTEXTS) if hard else {p.get("ctx") for p in params_by_id.values()} & set(HARD_CONTEXTS) + if not wanted: subprocess.run(["kubectl", "config", "use-context", CONTEXT], capture_output=True) return - ns_ac = next((p.get("ns") for p in params_by_id.values() if p.get("ns")), "default") - for name, ns in HARD_CONTEXTS.items(): + for name in sorted(wanted): subprocess.run(["kubectl", "config", "set-context", name, f"--cluster={cluster}", f"--user={user}", - f"--namespace={ns.replace('%{ns}', ns_ac)}"], capture_output=True) - subprocess.run(["kubectl", "config", "use-context", "k8s-c1-H"], capture_output=True) + f"--namespace={context_namespace(name, HARD_CONTEXTS[name], params_by_id)}"], capture_output=True) + # Hard mode starts you in c1 like the real exam; a mixed drill stays on the normal context + # so its non-hard tasks behave as usual and the hard ones still switch as instructed. + start = "k8s-c1-H" if hard and "k8s-c1-H" in wanted else CONTEXT + subprocess.run(["kubectl", "config", "use-context", start], capture_output=True) def _log_msg(*args: Any, **kwargs: Any) -> None: print(*args, **kwargs) @@ -557,11 +582,20 @@ for n in "$W1" "$W2" "$W3"; do ' /dev/null 2>&1 || true +# w1-taint: clear every randomized demo taint key the bank can apply. Q25 draws from +# dedicated/team/workload, Q107 from pool/lane, and Q307 pins maintenance=inprogress - miss one +# and that node silently stops accepting pods for every later session. +$K taint node "$W1" dedicated- workload- team- pool- lane- maintenance- >/dev/null 2>&1 || true + +# w1-block/w2-drain/w3-drain: Q307 cordons W1 and Q117 cordons W2, and Q208/Q309 have the +# candidate drain W2/W3. A cordon left behind fails any later task that counts pods per node +# (Q26/Q211 DaemonSet coverage, Q212's one-pod-per-node spread). +for n in "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done # NOTE: etcd/certs/apiserver corruption (hard-mode only: Q102, Q118 apiserver half, Q122, # Q123) isn't restored here yet - those are rarer and each needs a more bespoke repair. +# Q103's staticPodPath relocation also only reverts if /var/lib/kubelet/config.yaml.orig +# exists, which it does not unless something made that backup first. """ def restore_baseline(log: Callable[..., Any] = _log_msg): @@ -1103,10 +1137,15 @@ def cmd_web(args): shell_cmd = [Api.shell, "-C", f"source {rc}"] else: shell_cmd = [Api.shell, "--rcfile", rc, "-i"] - ttyd = subprocess.Popen(["ttyd", "-p", str(Api.ttyd_port), "-W", "-t", "fontSize=14", "-t", "theme={\"background\":\"#1a1b1e\"}", + # -W makes the terminal writable, so it must never be reachable off this machine: it is an + # unauthenticated root-equivalent shell with the cluster kubeconfig. ttyd binds every + # interface by default, so pin it to loopback (macOS needs lo0; override if yours differs). + iface = os.environ.get("CKA_TTYD_INTERFACE", "lo") + ttyd = subprocess.Popen(["ttyd", "-p", str(Api.ttyd_port), "-i", iface, "-W", + "-t", "fontSize=14", "-t", "theme={\"background\":\"#1a1b1e\"}", "--cwd", ROOT] + shell_cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - print(dim(f" terminal: ttyd on :{Api.ttyd_port} running {Api.shell} in {ROOT} with k=kubectl, $do and $now preset")) + print(dim(f" terminal: ttyd on {iface}:{Api.ttyd_port} running {Api.shell} in {ROOT} with k=kubectl, $do and $now preset")) else: print(yellow(" ttyd not found - the terminal pane will show install instructions.\n" " (dnf/apt/pacman/brew install ttyd, or https://github.com/tsl0922/ttyd/releases)")) diff --git a/tracks/cka/bank_hard.py b/tracks/cka/bank_hard.py index 8e3697e..b348f5a 100644 --- a/tracks/cka/bank_hard.py +++ b/tracks/cka/bank_hard.py @@ -349,6 +349,9 @@ for n in sorted(c): print(n, c[n])'); [ "$(grep . "$WS/q110/per-node.txt" 2>/dev }, { "id": 121, "domain": "WORK", "difficulty": "hard", "title": "The context's namespace", + # ctx_ns: this task reads the context's own default namespace instead of being told one, so + # k8s-c2-AC must be created pointing at *this* task's ns (see context_namespace in ./exam). + "ctx_ns": True, "params": lambda r: {"ctx": "k8s-c2-AC", "ns": ns_pick(r, "ac"), "dep": name_pick(r, "ledger")}, "task": CTX + """ In the **current namespace of that context** (do not pass `-n`), create Deployment `%{dep}`