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 <ids>' 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fqcio9TXkEXH35UHebTgoL
This commit is contained in:
vumast3r 2026-09-14 19:43:15 -06:00
parent 595003e377
commit df04c56533
2 changed files with 52 additions and 10 deletions

59
exam
View File

@ -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
done
# w1-taint: clear any of the randomized demo taint keys Q25/Q107 might have left behind
$K taint node "$W1" dedicated- workload- team- >/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)"))

View File

@ -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}`