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:
parent
595003e377
commit
df04c56533
59
exam
59
exam
@ -499,22 +499,47 @@ def preflight():
|
|||||||
|
|
||||||
HARD_CONTEXTS = {"k8s-c1-H": "default", "k8s-c2-AC": "%{ns}", "k8s-c3-CCC": "kube-system"}
|
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):
|
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}}"],
|
cluster = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.cluster}}"],
|
||||||
capture_output=True, text=True).stdout.strip()
|
capture_output=True, text=True).stdout.strip()
|
||||||
user = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.user}}"],
|
user = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.user}}"],
|
||||||
capture_output=True, text=True).stdout.strip()
|
capture_output=True, text=True).stdout.strip()
|
||||||
for name in HARD_CONTEXTS:
|
for name in HARD_CONTEXTS:
|
||||||
subprocess.run(["kubectl", "config", "delete-context", name], capture_output=True)
|
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)
|
subprocess.run(["kubectl", "config", "use-context", CONTEXT], capture_output=True)
|
||||||
return
|
return
|
||||||
ns_ac = next((p.get("ns") for p in params_by_id.values() if p.get("ns")), "default")
|
for name in sorted(wanted):
|
||||||
for name, ns in HARD_CONTEXTS.items():
|
|
||||||
subprocess.run(["kubectl", "config", "set-context", name, f"--cluster={cluster}", f"--user={user}",
|
subprocess.run(["kubectl", "config", "set-context", name, f"--cluster={cluster}", f"--user={user}",
|
||||||
f"--namespace={ns.replace('%{ns}', ns_ac)}"], capture_output=True)
|
f"--namespace={context_namespace(name, HARD_CONTEXTS[name], params_by_id)}"], capture_output=True)
|
||||||
subprocess.run(["kubectl", "config", "use-context", "k8s-c1-H"], 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:
|
def _log_msg(*args: Any, **kwargs: Any) -> None:
|
||||||
print(*args, **kwargs)
|
print(*args, **kwargs)
|
||||||
@ -557,11 +582,20 @@ for n in "$W1" "$W2" "$W3"; do
|
|||||||
' </dev/null
|
' </dev/null
|
||||||
done
|
done
|
||||||
|
|
||||||
# w1-taint: clear any of the randomized demo taint keys Q25/Q107 might have left behind
|
# w1-taint: clear every randomized demo taint key the bank can apply. Q25 draws from
|
||||||
$K taint node "$W1" dedicated- workload- team- >/dev/null 2>&1 || true
|
# 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,
|
# 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.
|
# 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):
|
def restore_baseline(log: Callable[..., Any] = _log_msg):
|
||||||
@ -1103,10 +1137,15 @@ def cmd_web(args):
|
|||||||
shell_cmd = [Api.shell, "-C", f"source {rc}"]
|
shell_cmd = [Api.shell, "-C", f"source {rc}"]
|
||||||
else:
|
else:
|
||||||
shell_cmd = [Api.shell, "--rcfile", rc, "-i"]
|
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,
|
"--cwd", ROOT] + shell_cmd, env=env,
|
||||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
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:
|
else:
|
||||||
print(yellow(" ttyd not found - the terminal pane will show install instructions.\n"
|
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)"))
|
" (dnf/apt/pacman/brew install ttyd, or https://github.com/tsl0922/ttyd/releases)"))
|
||||||
|
|||||||
@ -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",
|
"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")},
|
"params": lambda r: {"ctx": "k8s-c2-AC", "ns": ns_pick(r, "ac"), "dep": name_pick(r, "ledger")},
|
||||||
"task": CTX + """
|
"task": CTX + """
|
||||||
In the **current namespace of that context** (do not pass `-n`), create Deployment `%{dep}`
|
In the **current namespace of that context** (do not pass `-n`), create Deployment `%{dep}`
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user