diff --git a/exam b/exam index 6d4d786..a67b779 100755 --- a/exam +++ b/exam @@ -6,10 +6,12 @@ import http.server import importlib import json import os +import re import shutil import signal import subprocess import sys +import textwrap import threading import time import urllib.parse @@ -38,6 +40,8 @@ PASS_PCT = 0 STATE_FILE = "" HISTORY_FILE = "" WEAK_FILE = "" +STUDY_FILE = "" # tasks solved in study mode (the objective checklist reads it; history never does) +OBJECTIVES = [] # the track's curriculum, objective by objective (tracks//curriculum.py), if it has one K8S = "" # Kubernetes minor the track's real exam runs, e.g. "v1.35" CLUSTER = "" # kind cluster for that minor CONTEXT = "" @@ -65,6 +69,13 @@ def load_track(track_id): sys.exit(f"unknown track '{track_id}' (no tracks/{track_id}/questions.py)") importlib.reload(mod) track, questions = mod.TRACK, list(mod.QUESTIONS) + try: + cur = importlib.import_module(f"tracks.{track_id}.curriculum") + objectives = list(importlib.reload(cur).OBJECTIVES) + except ModuleNotFoundError as e: + if e.name != f"tracks.{track_id}.curriculum": + raise + objectives = [] # no objective map for this track: study/drill by domain only for q in questions: q["weight"] = sum(c["pts"] for c in q["checks"]) q.setdefault("difficulty", "normal") @@ -81,6 +92,7 @@ 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"), + STUDY_FILE=os.path.join(ROOT, f"study-{track_id}.json"), OBJECTIVES=objectives, # 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}"), @@ -639,6 +651,346 @@ def write_weak_spots(): with open(WEAK_FILE, "w") as f: f.write("\n".join(L)) +# --------------------------------------------------------- curriculum ---- +# A track may ship tracks//curriculum.py: the exam's objectives in order, each with study +# notes and the tasks that practise it. It turns the bank into a path - study an objective with +# the solutions open, then prove it against the clock - and `./exam objectives` shows where you are. +def find_objective(arg): + """An objective by id (3.2) or key (probes, or any unambiguous start of it); None if none fits.""" + a = str(arg).lower() + for o in OBJECTIVES: + if a in (o["id"], o["key"]): + return o + hits = [o for o in OBJECTIVES if len(a) >= 3 and o["key"].startswith(a)] + return hits[0] if len(hits) == 1 else None + +def objective_tasks(o, with_sets=False): + """An objective's tasks in its own order. Tasks of the fixed simulator sets stay out unless + asked for: meeting them one objective at a time would spoil the mock they are kept for.""" + return [QBYID[i] for i in o["tasks"] if i in QBYID and (with_sets or not QBYID[i].get("set"))] + +def objectives_of(qid): + return [o for o in OBJECTIVES if qid in o["tasks"]] + +def load_progress(): + """study-.json, next to the history so it survives ./teardown.sh as the history does: + tasks task id -> its latest result in a study session ("passed" sticks once earned) + objectives objective id -> when it was first studied and first completed + Study results are kept apart from the history on purpose: a pass with the solution open is + practice, not proof, so it never reaches the statistics, the weak list or the exam gate.""" + try: + with open(STUDY_FILE) as f: + d = json.load(f) + except (OSError, ValueError): + d = {} + return {"tasks": {int(k): v for k, v in d.get("tasks", {}).items()}, "objectives": dict(d.get("objectives", {}))} + +def save_progress(prog): + with open(STUDY_FILE, "w") as f: + json.dump({"tasks": {str(k): prog["tasks"][k] for k in sorted(prog["tasks"])}, + "objectives": {k: prog["objectives"][k] for k in sorted(prog["objectives"])}}, f, indent=1) + f.write("\n") + +def load_study(): + return load_progress()["tasks"] + +def record_study(st, ids=None): + """Note what a study session graded (all of it, or just `ids`).""" + if not untimed(st): + return + prog = load_progress(); log = prog["tasks"] + for k, r in st.get("results", {}).items(): + if ids is not None and int(k) not in ids: + continue + log[int(k)] = {"at": datetime.now().isoformat(timespec="minutes"), "pass": r["pass"], "earned": r["earned"], + "total": r["total"], "passed": bool(r["pass"] or log.get(int(k), {}).get("passed"))} + save_progress(prog) + +def task_state(qid, agg, studied): + """passed = the latest attempt against the clock was a full pass; failed = it was not; + studied = solved in study mode but never attempted against the clock; new = neither.""" + a = agg.get(int(qid)) + if a is not None and a.attempts: + return "passed" if a.last else "failed" + return "studied" if studied.get(int(qid), {}).get("passed") else "new" + +def task_solved(qid, agg, studied): + """Solved at least once, with the solution open or against the clock.""" + a = agg.get(int(qid)) + return bool(studied.get(int(qid), {}).get("passed") or (a is not None and a.attempts > a.fails)) + +MARKS = {"passed": ("✓", green), "studied": ("~", yellow), "failed": ("✗", red), "new": ("·", dim), "started": ("-", yellow)} + +def mark(state): + ch, col = MARKS[state] + return col(ch) + +def objective_state(o, agg, studied): + """The two stages every objective goes through, and where it is: + studied every pool task solved at least once (study mode counts) + done every pool task passed against the clock on its latest attempt - exam conditions + before that: started (something attempted) or new.""" + qs = objective_tasks(o) + states = [task_state(q["id"], agg, studied) for q in qs] + if qs and all(x == "passed" for x in states): + return "done" + if qs and all(task_solved(q["id"], agg, studied) for q in qs): + return "studied" + return "started" if any(x != "new" for x in states) else "new" + +OBJECTIVE_MARK = {"done": "passed", "studied": "studied", "started": "started", "new": "new"} + +def next_objective(agg=None, studied=None): + """(objective, how) for the first objective in curriculum order that is not done: "study" it + until every task has been solved once, then "drill" it against the clock. (None, None) when + every objective is done.""" + agg = stats() if agg is None else agg + studied = load_study() if studied is None else studied + for o in OBJECTIVES: + state = objective_state(o, agg, studied) + if state != "done": + return o, ("drill" if state == "studied" else "study") + return None, None + +def mock_scores(): + """Scores of the full mocks and of each fixed simulator set, from the history.""" + full, fixed = [], {k: [] for k in SETS} + members = {k: {q["id"] for q in set_questions(k)} for k in SETS} + for e in load_history(): + if e["mode"] != "exam": + continue + hit = next((k for k in SETS if set(e["ids"]) == members[k]), None) + (fixed[hit] if hit else full).append(e["score_pct"]) + return full, fixed + +def next_step(agg=None, studied=None): + """The next move on the path to the exam, as {kind, cmd, why[, objective | set]}: + study an objective -> drill it against the clock -> ... every objective -> a full mock -> + the fixed simulator sets. Each stage is closer to the real exam than the one before.""" + agg = stats() if agg is None else agg + studied = load_study() if studied is None else studied + o, how = next_objective(agg, studied) + if o and how == "study": + return {"kind": "study", "objective": o, "cmd": f"./exam study {o['id']}", + "why": f"{o['id']} {o['text']} - notes, then its tasks with no clock and the solutions open"} + if o: + return {"kind": "drill", "objective": o, "cmd": f"./exam drill {o['id']}", + "why": f"{o['id']} {o['text']} - studied; now the same tasks against the clock, no solution until graded"} + full, fixed = mock_scores() + if not any(x >= PASS_PCT for x in full): + return {"kind": "mock", "cmd": "./exam start", + "why": f"every objective is done - a full mock: {sum(QUOTA.values())} tasks, {TRACK['minutes']} minutes, no feedback until you submit"} + for k in SETS: + if not any(x >= PASS_PCT for x in fixed[k]): + return {"kind": "sim", "set": k, "cmd": f"./exam sim {k}", + "why": f"{SETS[k]}: {len(set_questions(k))} fixed tasks, {TRACK['minutes']} minutes, exam rules"} + return {"kind": "done", "cmd": "./exam start --hard", + "why": f"every objective done and every mock passed at {PASS_PCT}% or better - keep sharp with a fresh mock"} + +def update_milestones(): + """Stamp each objective the first time it is studied and the first time it is done, and + return what was newly reached as [(objective, "studied" | "done")]. Called whenever results + are recorded, so the checklist keeps the date you got there.""" + if not OBJECTIVES: + return [] + prog = load_progress(); agg = stats(); new = [] + now = datetime.now().isoformat(timespec="minutes") + for o in OBJECTIVES: + state = objective_state(o, agg, prog["tasks"]) + seen = prog["objectives"].setdefault(o["id"], {}) + for level in ("studied", "done"): + if state in (level, "done") and level not in seen: + seen[level] = now + new.append((o, level)) + if not seen: + del prog["objectives"][o["id"]] + if new: + save_progress(prog) + # reaching "done" in one go also stamps "studied"; only the higher one is news + return [(o, lv) for o, lv in new if not (lv == "studied" and (o, "done") in new)] + +def milestone_lines(new): + out = [] + for o, level in new: + n = len(objective_tasks(o)) + if level == "done": + out.append(f"✓ Objective {o['id']} {o['key']} complete - {n}/{n} tasks passed against the clock.") + else: + out.append(f"~ Objective {o['id']} {o['key']} studied - every task solved once. Prove it against the clock: ./exam drill {o['id']}") + return out + +def curriculum_gaps(): + """What would make the path lie: a task no objective lists, a listed id that does not exist, + an objective too thin to study.""" + listed = {i for o in OBJECTIVES for i in o["tasks"]} + out = [] + orphans = [q["id"] for q in QUESTIONS if q["id"] not in listed] + if orphans: + out.append(f"not listed under any objective: Q{', Q'.join(map(str, orphans))}") + ghosts = sorted(listed - set(QBYID)) + if ghosts: + out.append(f"listed but not in the bank: Q{', Q'.join(map(str, ghosts))}") + thin = [o["id"] for o in OBJECTIVES if len(objective_tasks(o)) < 2] + if thin: + out.append(f"fewer than two pool tasks: {', '.join(thin)}") + wrong = [f"{o['id']} ({o['domain']})" for o in OBJECTIVES if o["domain"] not in DOMAINS] + if wrong: + out.append(f"unknown domain: {', '.join(wrong)}") + return out + +def require_objectives(): + if not OBJECTIVES: + die(f"Track '{TRACK_ID}' has no objective map (tracks/{TRACK_ID}/curriculum.py).\n" + f" Study it by domain instead: ./exam study {next(iter(DOMAINS))}") + +def cmd_objectives(args): + """The curriculum as a checklist: every objective, its tasks, and how far you are.""" + require_objectives() + agg, studied = stats(), load_study() + cols = shutil.get_terminal_size((120, 24)).columns + sets = len(QUESTIONS) - len(POOL) + print(bold(f"{TRACK['name']} - study path") + dim(f" {len(OBJECTIVES)} objectives · {len(POOL)} tasks" + + (f" · {sets} more in the fixed sets [in brackets]" if sets else ""))) + print(dim("tasks: ✓ passed against the clock ~ solved in study mode ✗ failed last time · not yet" + " objectives: ✓ complete ~ studied - started")) + def cell(q): + return f"{MARKS[task_state(q['id'], agg, studied)][0]}{q['id']}", f"{mark(task_state(q['id'], agg, studied))}{q['id']}" + rows = [] + for o in OBJECTIVES: + pool = [cell(q) for q in objective_tasks(o)] + fixed = [cell(q) for q in objective_tasks(o, True) if q.get("set")] + plain = " ".join(c[0] for c in pool) + (" [" + " ".join(c[0] for c in fixed) + "]" if fixed else "") + shown = " ".join(c[1] for c in pool) + (dim(" [") + " ".join(c[1] for c in fixed) + dim("]") if fixed else "") + rows.append((o, plain, shown)) + tw = max(28, min(max(len(o["text"]) for o in OBJECTIVES), cols - 26 - max(len(r[1]) for r in rows))) + for d, name in DOMAINS.items(): + print(bold(f"\n{name}")) + for o, plain, shown in rows: + if o["domain"] != d: + continue + state = objective_state(o, agg, studied) + text = o["text"] if len(o["text"]) <= tw else o["text"][:tw - 1] + "…" + print(f" {mark(OBJECTIVE_MARK[state])} {o['id']:<4}{o['key']:<16}{text:<{tw}} {shown}") + print_path(agg, studied) + for g in curriculum_gaps(): + print(yellow(f"curriculum.py: {g}")) + +def print_path(agg=None, studied=None): + """The ladder from first reading to exam conditions, how far up you are, and the next rung.""" + agg = stats() if agg is None else agg + studied = load_study() if studied is None else studied + states = [objective_state(o, agg, studied) for o in OBJECTIVES] + n, done = len(OBJECTIVES), states.count("done") + full, fixed = mock_scores() + step = next_step(agg, studied) + def best(scores): + if not scores: + return dim("not sat yet") + b = max(scores) + return (green if b >= PASS_PCT else red)(f"best {b}%") + dim(f" of {len(scores)}, pass mark {PASS_PCT}%") + rows = [("study", "Study", "each objective's tasks, notes and solutions open", f"{done + states.count('studied')}/{n} objectives"), + ("drill", "Clock", "the same tasks as a timed drill, graded per check", f"{done}/{n} objectives")] + rows.append(("mock", "Mock exam", f"{sum(QUOTA.values())} random tasks, {TRACK['minutes']} min, no feedback until submit", best(full))) + rows += [("sim", f"Set {k.upper()}", f"{len(set_questions(k))} fixed tasks, {TRACK['minutes']} min, exam rules", best(fixed[k])) for k in SETS] + print(bold("\nPath to the exam")) + for i, (kind, name, what, where) in enumerate(rows, 1): + here = step["kind"] == kind and (kind != "sim" or name == f"Set {step['set'].upper()}") + print(f" {green('→') if here else ' '} {i} {name:<11}{dim(f'{what:<54}')}{where}") + print(bold(f"\nNext: {step['cmd']}") + dim(" (or just ./exam next)")) + print(dim(f" {step['why']}")) + if step.get("objective"): + print(dim(f" ./exam notes {step['objective']['id']} what to know, the fastest commands, the traps, the docs to open")) + +def cmd_next(args): + """Start whatever is next on the path: study an objective, drill it, a mock, a simulator set.""" + require_objectives() + step = next_step() + print(bold(f"Next on the path: {step['cmd']}") + dim(f"\n {step['why']}")) + if step["kind"] == "study": + cmd_study([step["objective"]["id"]] + args) + elif step["kind"] == "drill": + cmd_drill([step["objective"]["id"]] + args) + elif step["kind"] == "sim": + cmd_sim([step["set"]] + args) + else: + cmd_start((["--hard"] if step["kind"] == "done" else []) + args) + +def paint_code(text): + """`code` in a note: highlighted on a terminal, left as written (it is Markdown) in a pipe.""" + return re.sub(r"`([^`]+)`", lambda m: bold(m.group(1)), text) if TTY else text + +def print_notes(o, agg=None, studied=None): + agg = stats() if agg is None else agg + studied = load_study() if studied is None else studied + w = min(shutil.get_terminal_size((100, 24)).columns, 104) + def bullets(items): + for it in items: + print(paint_code("\n".join(textwrap.wrap(it, w, initial_indent=" - ", subsequent_indent=" ")))) + print(bold(f"{o['id']} {o['key']}") + dim(f" · {DOMAINS[o['domain']]}")) + print(bold(o["text"])); print("-" * 64) + print(bold("Know")); bullets(o["know"]) + print(bold("\nFast")); print("\n".join(" " + line for line in o["fast"].strip("\n").splitlines())) + print(bold("\nTraps")); bullets(o["traps"]) + print(bold("\nDocs") + dim(" (pages you may open during the exam)")) + for u in o["docs"]: + print(" " + u) + print(bold("\nTasks")) + for q in objective_tasks(o, True): + print(f" {mark(task_state(q['id'], agg, studied))} Q{q['id']:<3} {q['title']}" + (dim(f" [{SETS.get(q['set'], q['set'])}]") if q.get("set") else "")) + print("-" * 64) + +def notes_markdown(objs): + """The same notes as a Markdown document (./exam notes all --md > CKAD_STUDY_GUIDE.md).""" + L = [f"# {TRACK_ID.upper()} study guide", "", + f"The {len(OBJECTIVES)} objectives of the {TRACK['name']} curriculum, in its own words and order: what to know, " + "the fastest commands, the traps, the documentation pages the exam lets you open, and the tasks in this " + "simulator that practise each one.", "", + f"_Generated by `./exam --track {TRACK_ID} notes all --md` from `tracks/{TRACK_ID}/curriculum.py` - edit that file, not this one._", "", + "```", f"./exam objectives # the path and your progress", "./exam study 3.2 # one objective: these notes, then its tasks, no clock, solutions open", + "./exam drill 3.2 # the same tasks against the clock", "```", ""] + for d, name in DOMAINS.items(): + mine = [o for o in objs if o["domain"] == d] + if not mine: + continue + L += [f"## {name}", ""] + for o in mine: + L += [f"### {o['id']} {o['text']}", "", f"`./exam study {o['id']}` (or `{o['key']}`)", "", "**Know**", ""] + L += [f"- {x}" for x in o["know"]] + L += ["", "**Fast**", "", "```", o["fast"].strip("\n"), "```", "", "**Traps**", ""] + L += [f"- {x}" for x in o["traps"]] + L += ["", "**Docs**", ""] + [f"- <{u}>" for u in o["docs"]] + L += ["", "**Tasks**", ""] + L += [f"- Q{q['id']} {q['title']}" + (f" _({SETS.get(q['set'], q['set'])})_" if q.get("set") else "") for q in objective_tasks(o, True)] + L.append("") + return "\n".join(L) + +def cmd_notes(args): + """Study notes for an objective, a domain or the whole curriculum.""" + require_objectives() + rest = [a for a in args if not a.startswith("--")] + if not rest: + st = load_state() + o = find_objective(st.get("objective")) if st and not st.get("ended_at") and st.get("objective") else None + objs = [o or next_objective()[0] or OBJECTIVES[0]] + elif rest[0] == "all": + objs = list(OBJECTIVES) + elif rest[0].upper() in DOMAINS: + objs = [o for o in OBJECTIVES if o["domain"] == rest[0].upper()] + else: + objs = [find_objective(a) for a in rest] + if None in objs: + die(f"'{rest[objs.index(None)]}' is not an objective. Ids and keys:\n " + + "\n ".join(f"{o['id']:<5}{o['key']:<16}{o['text']}" for o in OBJECTIVES)) + if "--md" in args: + print(notes_markdown(objs)); return + agg, studied = stats(), load_study() + for i, o in enumerate(objs): + if i: + print() + print_notes(o, agg, studied) + if len(objs) == 1: + print(dim(f"./exam study {objs[0]['id']} its tasks, no clock, solutions open ./exam drill {objs[0]['id']} against the clock")) + # ------------------------------------------------------------ session ---- def preflight(): out = subprocess.run(["kubectl", "--context", CONTEXT, "get", "nodes", "-o", "jsonpath={.items[*].metadata.name}"], @@ -955,7 +1307,8 @@ def use_kubectl(): except OSError: pass -def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False, other_ok=None): +def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False, other_ok=None, + objective=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.") @@ -985,19 +1338,24 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, 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, - "k8s": server_version(), "results": {}, "flags": [], "notes": ""} + "k8s": server_version(), "results": {}, "flags": [], "notes": "", + "objective": objective} # the curriculum objective a study/drill session was started for, if any 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")) + kind = "STUDY" if untimed_session else ("EXAM" if mode == "exam" else "DRILL") + log(bold(f" {TRACK_ID.upper()} {kind}{' · HARD' if hard else ''} - {'no clock, solutions open' if untimed_session else 'clock started'}")) 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}%") + if not untimed_session: + 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)}" + + ("" if untimed_session else f" Pass mark: {PASS_PCT}%")) log(bold("=" * 64)) log(" ./exam web exam-style browser UI (task pane + terminal + clock)\n" - " ./exam list / show N / grade N / timer / end terminal workflow\n" + + (" ./exam list / show N / grade N / solution N / end terminal workflow\n" if untimed_session else + " ./exam list / show N / grade N / timer / end terminal workflow\n") + (" Tip: read ALL tasks first. Some are cluster-wide failures that block others.\n" if mode == "exam" else "") + f" Files the tasks ask for go under {WS_DISPLAY}/ (relative to this directory)\n" + " Your own terminal: source ./examrc (fish: source ./examrc.fish) -> k, $do, $now, completion") @@ -1017,9 +1375,15 @@ def finish_session(st, log: Callable[..., Any] = _log_msg): st["over_time"] = over save_state(st) setup_contexts(False, {}) + entry = None if untimed(st): - return None - return record_attempt(st) + record_study(st) + else: + entry = record_attempt(st) + # objectives this session got over a line: shown by ./exam end and by the results page + st["milestones"] = milestone_lines(update_milestones()) + save_state(st) + return entry # ----------------------------------------------------------- commands ---- def cmd_start(args): @@ -1062,6 +1426,7 @@ def cmd_drill(args): print(bold(f"Campaign: {label}") + dim(f" this batch: Q{', Q'.join(str(q['id']) for q in qs)}")) begin_session(qs, "drill", (minutes * 60) if minutes else per_task * len(qs), force, hard=hard) return + objective = None if args[0] == "weak": ids = weak_ids() if not ids: @@ -1075,9 +1440,13 @@ def cmd_drill(args): ids += [q["id"] for q in QUESTIONS if q["domain"] == a.upper()] elif a.isdigit(): ids.append(q_by_id(int(a))["id"]) + elif find_objective(a): + ids += [q["id"] for q in objective_tasks(find_objective(a))] + objective = objective or find_objective(a)["id"] else: - die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}) or a set " - f"({', '.join('set' + k for k in SETS) or 'none'}).") + die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), a set " + f"({', '.join('set' + k for k in SETS) or 'none'})" + + (" or an objective (./exam objectives)." if OBJECTIVES else ".")) ids = sorted(set(ids)) qs, solo = split_solo([q for q in QUESTIONS if q["id"] in ids]) if solo: @@ -1091,16 +1460,18 @@ def cmd_drill(args): # order you meet them in does not. A drill listed by id walks you from easy to hard in # lockstep, which is the one ordering the real exam will never give you. random.Random().shuffle(kept) - begin_session(kept, "drill", (minutes * 60) if minutes else per_task * len(kept), force, hard=hard) + begin_session(kept, "drill", (minutes * 60) if minutes else per_task * len(kept), force, hard=hard, + objective=objective if len(args) == 1 else None) def cmd_study(args): """No clock, solutions unlocked from the start, nothing written to history.""" - hard = "--hard" in args; force = "--force" in args + hard = "--hard" in args; force = "--force" in args; with_sets = "--all" in args rest = [a for a in args if not a.startswith("--")] n = DRILL_SIZE if "--count" in args: n = int(args[args.index("--count") + 1]); rest = [a for a in rest if a != str(n)] rng = random.Random() + obj = None if rest and rest[0].upper() in DOMAINS: domain = rest[0].upper() qs = pick(domain, n, hard, rng) @@ -1108,13 +1479,31 @@ def cmd_study(args): elif rest and rest[0] == "weak": qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])[0], n) print(bold("Study: tasks you have been failing")) + elif OBJECTIVES and (not rest or rest[0] == "next" or find_objective(rest[0])): + # one objective of the curriculum: the one named, else the next one on the path + if rest and rest[0] != "next": + obj = find_objective(rest[0]) + else: + agg, studied = stats(), load_study() + obj = next((o for o in OBJECTIVES if objective_state(o, agg, studied) in ("new", "started")), None) + if obj is None: + die("Every objective has been studied - name one to revisit (./exam objectives lists them),\n" + f" or carry on up the path: {next_step(agg, studied)['cmd']} (./exam next)") + qs = no_conflicts(split_solo(objective_tasks(obj, with_sets))[0], len(obj["tasks"])) + print(bold(f"Study: {obj['id']} {obj['text']}") + dim(f" {DOMAINS[obj['domain']]}")) elif rest: - qs = no_conflicts(split_solo([q_by_id(int(a)) for a in rest])[0], len(rest)) + qs = no_conflicts(split_solo([q_by_id(int(a)) if a.isdigit() else die( + f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}), 'weak'" + + (" or an objective (./exam objectives)." if OBJECTIVES else ".")) for a in rest])[0], len(rest)) print(bold("Study: " + ", ".join(f"Q{q['id']}" for q in qs))) else: domain, qs = next_drill(hard, rng) print(bold(f"Study: {DOMAINS[domain]}")) - begin_session(qs, "drill", EXAM_SECONDS, force, hard=hard, untimed_session=True) + begin_session(qs, "drill", EXAM_SECONDS, force, hard=hard, untimed_session=True, objective=obj and obj["id"]) + if obj: + print(); print_notes(obj) + print(dim(" ./exam show N read a task ./exam grade N check it ./exam solution N reference answer\n" + " ./exam notes these notes again ./exam end finish (./exam objectives then shows what you solved)")) def cmd_sim(args): """A fixed simulator set, full exam rules and clock.""" @@ -1175,6 +1564,10 @@ def cmd_grade(args): tag = green("PASS") if r.get("pass") else yellow(f"{r.get('earned')}/{r.get('total')}") print(f"Q{n:02d} {tag} {q['title']}"); print(dim(result_detail(r))) save_state(st) + record_study(st, set(ids)) + if untimed(st): + for line in milestone_lines(update_milestones()): + print(green(line)) if args[0] == "all": print_score(st) @@ -1194,7 +1587,7 @@ def print_score(st): def status_line(st): if untimed(st): - return green("study mode - no clock") + dim(f" [{len(session_questions(st))} tasks, nothing recorded]") + return green("study mode - no clock") + dim(f" [{len(session_questions(st))} tasks, kept out of the history]") r = remaining(st) if st.get("paused_at"): return yellow(f"⏸ PAUSED - {fmt(r)} remaining") + dim(" ./exam resume") @@ -1252,13 +1645,22 @@ def cmd_end(args): print(yellow("Note: time expired before submission.")) entry = finish_session(st) print_score(st) + def path_note(): + if not OBJECTIVES: + return + print() + for line in st.get("milestones", []): + print(green(line)) + step = next_step() + print(bold(f"Next on the path: {step['cmd']}") + dim(f" {step['why']}")) if entry is None: - print(dim("Study session - not added to history or weak-spots.")); return + print(dim("Study session - not added to history or weak-spots.")); path_note(); return print(dim(f"Time used: {fmt(st['ended_at'] - st['started_at'] - paused_total(st))}" + (" (over time)" if st.get("over_time") else ""))) failed = sorted(int(k) for k, v in entry["results"].items() if not v["pass"]) print(dim(f"Attempt #{len(load_history())} logged to {os.path.basename(HISTORY_FILE)}; {os.path.basename(WEAK_FILE)} updated.")) if failed: print(bold("\nTo drill what you missed: ./exam drill weak") + dim(f" (Q{', Q'.join(map(str, failed))})")) + path_note() def cmd_abort(args): """End a session without grading it and restore the kubectl context.""" @@ -1425,6 +1827,9 @@ class Api(http.server.BaseHTTPRequestHandler): if "results" not in st or not isinstance(st["results"], dict): st["results"] = {} st["results"][str(q["id"])] = grade_one(q, session_params(st, q)); save_state(st) + record_study(st, {q["id"]}) + if untimed(st): + update_milestones() elif p.path == "/api/end": if not st.get("ended_at"): if not kubectl_ok(): # grading an unreachable cluster would record a 0% attempt @@ -1550,12 +1955,18 @@ def cmd_clusters(args): def cmd_help(args): print(__doc__ + f"\nversion {VERSION}") + objective_help = "" if not OBJECTIVES else ( + f" objectives the curriculum as a path to the exam: {len(OBJECTIVES)} objectives, their tasks, your progress\n" + " next start the next step on that path (study an objective, then drill it, then the mocks)\n" + " notes [OBJ|DOMAIN|all] what to know, fastest commands, traps and docs for an objective (3.2 or probes)\n" + " study [OBJ|next] an objective's notes and tasks; with no argument, the next one on the path\n" + " (--all adds its Simulator Set tasks); drill OBJ = the same tasks against the clock\n") 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}) - drill next domain you have drilled least: {DRILL_SIZE} tasks, 6 min each (--hard: 5 min, hard tasks) + study [DOMAIN|weak|N..] no clock, solutions unlocked, kept out of the history (--count N, default {DRILL_SIZE}) +{objective_help} drill next domain you have drilled least: {DRILL_SIZE} tasks, 6 min each (--hard: 5 min, hard tasks) drill all [--hard] campaign: {DRILL_SIZE} never-attempted tasks per session until the bank is done, then weak ones drill weak|N..|DOMAIN.. chosen tasks (--time MIN, --force, --hard) progress attempted/passed per domain, normal and hard @@ -1579,7 +1990,8 @@ def cmd_help(args): 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, - "clusters": cmd_clusters, "cluster-env": cmd_cluster_env} + "clusters": cmd_clusters, "cluster-env": cmd_cluster_env, + "objectives": cmd_objectives, "curriculum": cmd_objectives, "notes": cmd_notes, "next": cmd_next} if __name__ == "__main__": cmd = ARGV[0] if ARGV else "help" diff --git a/tracks/ckad/curriculum.py b/tracks/ckad/curriculum.py new file mode 100644 index 0000000..629239a --- /dev/null +++ b/tracks/ckad/curriculum.py @@ -0,0 +1,700 @@ +"""CKAD curriculum as a study path: the 24 objectives of the five domains, in the curriculum's own +words and order (CKAD_Curriculum_v1.37.pdf, cncf/curriculum), each with what to know, the fastest +commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it. + +./exam objectives the path, with your progress per objective +./exam notes 3.2 the notes for one objective (id or key: 3.2, probes) +./exam study 3.2 those notes, then its tasks with no clock and solutions visible +./exam drill 3.2 the same tasks against the clock + +"tasks" lists every task that really exercises the objective, most direct first; a task may sit +under several objectives. Study and drill sessions use the pool tasks; the Simulator Set A tasks +(38-57) are listed too but stay out unless you ask for them (--all), so the mock stays unspoiled. +Every task must be listed at least once and every objective needs two pool tasks - +`./exam objectives` says so when that stops being true. +""" + +OBJECTIVES = [ +# ------------------------------------------------- 1. Application Design and Build (20%) ---- +{ + "id": "1.1", "key": "images", "domain": "DESIGN", + "text": "Define, build and modify container images", + "tasks": [13, 60, 41], + "know": [ + "Read and change a Dockerfile: FROM, COPY, RUN, ENV, ARG (build time only), WORKDIR, USER, LABEL, EXPOSE, ENTRYPOINT, CMD.", + "ENTRYPOINT is the program, CMD its default arguments. In a Pod, `command` replaces ENTRYPOINT and `args` replaces CMD.", + "Build, tag, list, inspect, run, save and remove images. The exam terminal has docker or podman - the verbs are the same.", + "A tag is only a pointer: one image can carry several tags, and rebuilding under an old tag moves it.", + ], + "fast": """ +podman build -t localhost/app:v1 . # -f path/Dockerfile --build-arg KEY=val +podman tag localhost/app:v1 localhost/app:latest +podman images | grep app +podman inspect localhost/app:v1 --format '{{.Config.User}} {{.Config.Entrypoint}} {{.Config.Cmd}} {{.Config.Env}}' +podman run --rm localhost/app:v1 [args replacing CMD] # -d --name x keeps it running; podman logs x +podman save --format oci-archive -o app.tar localhost/app:v1 # docker save -o app.tar ... +podman rmi localhost/app:v0 +""", + "traps": [ + "An ARG is gone at run time unless you copy it into an ENV (`ARG V` then `ENV APP_VERSION=$V`).", + "`podman save` writes a docker-archive unless you pass `--format oci-archive`.", + "Build before you edit when a task wants the old version kept under its own tag.", + "imagePullPolicy defaults to Always for `:latest` (or no tag) and IfNotPresent for any other tag.", + ], + "docs": ["https://kubernetes.io/docs/concepts/containers/images/", + "https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/"], +}, +{ + "id": "1.2", "key": "workloads", "domain": "DESIGN", + "text": "Choose and use the right workload resource (Deployment, DaemonSet, CronJob, etc.)", + "tasks": [3, 14, 15, 39, 38, 44, 56], + "know": [ + "Choose by behaviour: Deployment (interchangeable replicas, rolling updates), StatefulSet (stable name, DNS and storage per replica), DaemonSet (one Pod per node), Job (run to completion), CronJob (Jobs on a schedule), bare Pod (one-off).", + "Job: completions, parallelism, backoffLimit, activeDeadlineSeconds, ttlSecondsAfterFinished; restartPolicy must be Never or OnFailure.", + "CronJob: schedule, startingDeadlineSeconds, concurrencyPolicy (Allow, Forbid, Replace), suspend, successfulJobsHistoryLimit, failedJobsHistoryLimit.", + "DaemonSet: no replicas; a toleration for node-role.kubernetes.io/control-plane:NoSchedule puts it on control-plane nodes too.", + ], + "fast": """ +k create deploy web --image=nginx:1.27 --replicas=3 $do > deploy.yaml +k create job render --image=busybox:1.36 $do -- sh -c 'echo done' > job.yaml +k create cronjob digest --image=busybox:1.36 --schedule='*/5 * * * *' $do -- sh -c 'echo digest' > cj.yaml +k create job digest-manual --from=cronjob/digest # run a CronJob once, now +k run tmp --image=busybox:1.36 --restart=Never --rm -it -- sh +# DaemonSet has no generator: create a Deployment with $do, change kind, delete replicas/strategy/status +k explain cronjob.spec.jobTemplate.spec --recursive | less +""", + "traps": [ + "Three nested specs in a CronJob: spec (schedule, startingDeadlineSeconds) > jobTemplate.spec (completions, activeDeadlineSeconds) > template.spec (containers, restartPolicy).", + "A Job's Pod template is immutable - delete the Job and create it again instead of editing it.", + "A StatefulSet needs `serviceName` naming a headless Service (clusterIP: None) before its Pods get their own DNS names.", + ], + "docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/job/", + "https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/", + "https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/"], +}, +{ + "id": "1.3", "key": "multicontainer", "domain": "DESIGN", + "text": "Understand multi-container Pod design patterns (e.g. sidecar, init and others)", + "tasks": [1, 2, 16, 40], + "know": [ + "Containers of a Pod share its network (they reach each other on localhost) and any volume both mount - that is all a sidecar, adapter or ambassador is.", + "Init containers run one at a time, in order, each to completion, before any app container starts.", + "A native sidecar is an init container with `restartPolicy: Always`: it starts before the app containers, keeps running beside them, and does not keep a Job from completing.", + "Address one container with `-c`: `k logs POD -c NAME`, `k exec POD -c NAME -- ...`. Status `Init:0/1` means an init container is still running.", + ], + "fast": """ +k run app --image=nginx:1.27 $do > pod.yaml # then add the second container by hand +spec: + initContainers: + - {name: wait, image: busybox:1.36, command: [sh, -c, "until wget -qO- -T2 http://backend; do sleep 2; done"]} + - {name: shipper, image: busybox:1.36, restartPolicy: Always, command: [sh, -c, "tail -F /logs/app.log"], + volumeMounts: [{name: logs, mountPath: /logs}]} # native sidecar + containers: + - {name: app, image: nginx:1.27, volumeMounts: [{name: logs, mountPath: /var/log/app}]} + volumes: [{name: logs, emptyDir: {}}] +k logs app -c shipper; k logs app --all-containers --prefix +""", + "traps": [ + "`kubectl run` creates one container only - generate the Pod and add the others in the YAML.", + "A Service with no endpoints still resolves in DNS, so an init container waiting on `nslookup` is not waiting for the app. Wait on a real request.", + "A classic sidecar that never exits keeps a Job running for ever; make it a native sidecar.", + ], + "docs": ["https://kubernetes.io/docs/concepts/workloads/pods/init-containers/", + "https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/"], +}, +{ + "id": "1.4", "key": "volumes", "domain": "DESIGN", + "text": "Utilize persistent and ephemeral volumes", + "tasks": [17, 10, 52, 40, 50], + "know": [ + "Ephemeral: emptyDir (lives as long as the Pod; `medium: Memory` is tmpfs, `sizeLimit` caps it), and the configMap, secret, downwardAPI and projected volumes.", + "Persistent: a PersistentVolumeClaim asks for storage (accessModes, resources.requests.storage, storageClassName); a StorageClass provisions the PersistentVolume, or a pre-made one binds.", + "Access modes: ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod.", + "volumeMounts: mountPath, readOnly, and subPath to mount one file or directory without hiding the rest of the target directory.", + ], + "fast": """ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: {name: data} +spec: + accessModes: [ReadWriteOnce] + storageClassName: standard + resources: {requests: {storage: 100Mi}} +--- # in the Pod spec: +volumes: + - {name: data, persistentVolumeClaim: {claimName: data}} + - {name: scratch, emptyDir: {medium: Memory, sizeLimit: 32Mi}} +containers: + - volumeMounts: [{name: data, mountPath: /data}, {name: scratch, mountPath: /scratch}] +k get pvc,pv; k describe pvc data # Pending? the Events say why +""", + "traps": [ + "There is no `kubectl create pvc` - copy the claim from the docs.", + "With a WaitForFirstConsumer StorageClass (`standard` here) a claim stays Pending until a Pod uses it. That is not an error.", + "emptyDir survives a container restart but not a new Pod. To prove data persists, delete the Pod and read the file again.", + "A non-root container cannot write to a fresh volume unless the Pod sets `securityContext.fsGroup`.", + ], + "docs": ["https://kubernetes.io/docs/concepts/storage/persistent-volumes/", + "https://kubernetes.io/docs/concepts/storage/volumes/", + "https://kubernetes.io/docs/tutorials/configuration/configure-persistent-volume-storage/"], +}, +# ------------------------------------------------------- 2. Application Deployment (20%) ---- +{ + "id": "2.1", "key": "strategies", "domain": "DEPLOY", + "text": "Use Kubernetes primitives to implement common deployment strategies (e.g. blue/green or canary)", + "tasks": [5, 18], + "know": [ + "Blue/green: two Deployments, and the Service selector decides which one gets all the traffic. Cut over by changing the selector; roll back by changing it back.", + "Canary: two Deployments whose Pods share the label the Service selects. Traffic splits by the number of ready Pods: 1 canary beside 4 stable is 20%.", + "A Deployment's own strategy is RollingUpdate (default) or Recreate (every old Pod stops first - downtime).", + ], + "fast": """ +k get svc web -o wide; k get pods --show-labels +k set selector svc web 'app=web,version=green' # blue/green cut-over +k scale deploy web-stable --replicas=4 # canary ratio: 4 stable + 1 canary = 20% +k get endpointslices -l kubernetes.io/service-name=web -o wide +k get pods -l app=web -L version,track +""", + "traps": [ + "Wait until the new Deployment is fully available before you switch the selector.", + "`kubectl set selector` replaces the whole selector - name every label you want in it.", + "A canary only shares traffic if the Service selector leaves out the label that tells the two versions apart.", + ], + "docs": ["https://kubernetes.io/docs/concepts/workloads/management/", + "https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"], +}, +{ + "id": "2.2", "key": "rollouts", "domain": "DEPLOY", + "text": "Understand Deployments and how to perform rolling updates", + "tasks": [4, 19, 42, 44], + "know": [ + "Any change to `spec.template` starts a rollout: a new ReplicaSet scales up while the old one scales down, within maxSurge and maxUnavailable (25% each by default).", + "minReadySeconds (how long a Pod must be Ready to count as available), revisionHistoryLimit (old ReplicaSets kept), progressDeadlineSeconds.", + "`kubectl rollout`: status, history, undo, pause, resume, restart.", + ], + "fast": """ +k set image deploy/web nginx=nginx:1.27 # CONTAINER=IMAGE +k rollout status deploy/web +k rollout history deploy/web # --revision=3 shows that revision's template +k rollout undo deploy/web # --to-revision=2 +k rollout restart deploy/web # same spec, new Pods +k annotate deploy/web kubernetes.io/change-cause='pick 1.27' +k patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}' +""", + "traps": [ + "`--record` is deprecated: write the kubernetes.io/change-cause annotation yourself.", + "An undo makes a new revision out of the old one, so the active revision number is not the one you rolled back to.", + "A rollout stuck on a bad image does not fail by itself - `rollout status` just waits, and the old Pods keep serving.", + "maxSurge and maxUnavailable cannot both be 0.", + ], + "docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"], +}, +{ + "id": "2.3", "key": "helm", "domain": "DEPLOY", + "text": "Use the Helm package manager to deploy existing packages", + "tasks": [6, 21, 43], + "know": [ + "A chart is the package, a release is one installation of it in a Namespace, and every install, upgrade or rollback adds a revision.", + "Values: the chart's defaults (`helm show values`), overridden with `-f file.yaml` and `--set key=value`; `helm get values` shows what a release was given.", + "Life cycle: repo add/update, search, install, upgrade, rollback, uninstall, list, history, status.", + ], + "fast": """ +helm repo add NAME URL && helm repo update; helm search repo nginx --versions +helm show values CHART > values.yaml +helm install REL CHART -n NS --create-namespace -f values.yaml --set replicaCount=2 # --version X +helm upgrade REL CHART -n NS --reuse-values --set replicaCount=4 +helm list -n NS # -A for every Namespace; --failed, --pending filter by status +helm history REL -n NS; helm rollback REL 1 -n NS; helm uninstall REL -n NS +helm get values REL -n NS # --all adds the defaults; helm get manifest REL -n NS +""", + "traps": [ + "Releases live in a Namespace: without `-n` Helm looks in `default` and finds nothing.", + "An upgrade without `--reuse-values` (or the same `-f`/`--set` again) puts every value back to the chart's default.", + "A rollback is a new revision, not a step back in the numbering.", + "Helm 3's `helm list` leaves out pending releases unless you add `-a`; Helm 4 (on this machine) lists every status and has no `-a`. `--pending` and `--failed` work in both.", + ], + "docs": ["https://helm.sh/docs/intro/using_helm/", "https://helm.sh/docs/helm/helm_upgrade/"], +}, +{ + "id": "2.4", "key": "kustomize", "domain": "DEPLOY", + "text": "Kustomize", + "tasks": [20, 61, 45], + "know": [ + "A kustomization.yaml lists `resources` (files or other kustomizations) and transforms them: namespace, namePrefix, nameSuffix, labels, images, replicas, patches.", + "Generators build ConfigMaps and Secrets from files, env files or literals, add a content hash to the name and rewrite every reference to it.", + "Base and overlays: an overlay lists `../../base` under resources and changes only what differs.", + "`kubectl kustomize DIR` prints the result; `kubectl apply -k DIR` applies it.", + ], + "fast": """ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: [../../base] # or files: [deployment.yaml, service.yaml] +namespace: prod +namePrefix: prod- +labels: [{pairs: {env: prod}, includeSelectors: false}] +images: [{name: nginx, newTag: "1.27"}] +replicas: [{name: web, count: 3}] +patches: [{path: patch.yaml}] # a partial Deployment with the same name +configMapGenerator: [{name: settings, envs: [settings.env]}] +secretGenerator: [{name: token, literals: [token=abc]}] +generatorOptions: {disableNameSuffixHash: true} +k kustomize DIR | less; k apply -k DIR; k diff -k DIR +""", + "traps": [ + "Always read the render before you apply it.", + "`images`, `replicas` and patches name things as the base does, before any prefix or suffix.", + "Refer to a generated ConfigMap by its plain name - Kustomize appends the hash everywhere it is used.", + "`namespace:` does not create the Namespace.", + ], + "docs": ["https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/"], +}, +# --------------------------------- 3. Application Observability and Maintenance (15%) ---- +{ + "id": "3.1", "key": "deprecations", "domain": "OBS", + "text": "Understand API deprecations", + "tasks": [22, 58], + "know": [ + "Every object is a kind in an API group at a version (apps/v1 Deployment). Versions are deprecated, keep being served for some releases, then are removed.", + "What this cluster serves: `kubectl api-resources` (the APIVERSION column), `kubectl api-versions`, `kubectl explain KIND`.", + "A removed version fails with `no matches for kind \"X\" in version \"g/v\"`; a deprecated one still works and prints `Warning: ... is deprecated`.", + "Migrating is the new apiVersion plus whatever changed shape: an Ingress backend becomes `service: {name, port: {number}}` with a required pathType, and an apps/v1 Deployment needs `spec.selector`.", + ], + "fast": """ +k api-resources | grep -i -E 'cronjob|ingress' # NAME SHORTNAMES APIVERSION NAMESPACED KIND +k api-versions | grep batch +k explain hpa | head -4 # GROUP, KIND, VERSION +k apply -f old.yaml --dry-run=server # the error or warning, without changing anything +# removed, and what replaced them: +# extensions/v1beta1, networking.k8s.io/v1beta1 Ingress -> networking.k8s.io/v1 (1.22) +# extensions/v1beta1, apps/v1beta1, apps/v1beta2 Deployment -> apps/v1 (1.16) +# batch/v1beta1 CronJob -> batch/v1 (1.25) +# policy/v1beta1 PodDisruptionBudget -> policy/v1 (1.25) +# autoscaling/v2beta1, v2beta2 HorizontalPodAutoscaler -> autoscaling/v2 (1.25, 1.26) +""", + "traps": [ + "The `kubectl convert` plugin is usually not installed - know the Ingress and Deployment rewrites by hand.", + "`kubectl get` shows an object in the API's preferred version whatever version it was written with.", + "A warning is not an error: the object was created. Read stderr.", + ], + "docs": ["https://kubernetes.io/docs/reference/using-api/deprecation-guide/", + "https://kubernetes.io/docs/reference/deprecation-policy/"], +}, +{ + "id": "3.2", "key": "probes", "domain": "OBS", + "text": "Implement probes and health checks", + "tasks": [7, 62, 46], + "know": [ + "Liveness: failing restarts the container. Readiness: failing takes the Pod out of Service endpoints, no restart. Startup: holds the other two off until it has passed once.", + "Handlers: httpGet (path, port), tcpSocket (port), exec (command), grpc (port).", + "Timing: initialDelaySeconds (0), periodSeconds (10), timeoutSeconds (1), failureThreshold (3), successThreshold (1). A startup probe allows failureThreshold x periodSeconds to come up.", + ], + "fast": """ +containers: + - name: app + startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12} # 60 s to start + readinessProbe: {httpGet: {path: /ready, port: 8080}, periodSeconds: 5, failureThreshold: 1} + livenessProbe: {tcpSocket: {port: 80}, initialDelaySeconds: 15, periodSeconds: 10} + # exec: readinessProbe: {exec: {command: [cat, /tmp/ready]}, periodSeconds: 5} +k describe pod POD | grep -E 'Liveness|Readiness|Startup' +k get events --field-selector reason=Unhealthy +k explain pod.spec.containers.livenessProbe +""", + "traps": [ + "No kubectl flag adds a probe: generate the YAML and edit it, or `kubectl edit`.", + "Probes belong to a container, not to the Pod spec.", + "Restarts climbing with `Unhealthy` and `Killing` events but nothing wrong in the logs: the liveness probe is wrong or too impatient, not the app.", + "Running, 0/1 Ready and no restarts: the readiness probe is failing.", + ], + "docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/", + "https://kubernetes.io/docs/concepts/workloads/pods/probes/"], +}, +{ + "id": "3.3", "key": "monitoring", "domain": "OBS", + "text": "Use built-in CLI tools to monitor Kubernetes applications", + "tasks": [23, 63, 48, 38], + "know": [ + "`kubectl get` output: -o wide, yaml, json, name, jsonpath=, custom-columns=; narrowed with -l (labels), --field-selector, -n / -A; ordered with --sort-by.", + "`kubectl top pod|node` shows live CPU and memory from metrics-server; `--containers` splits a Pod up.", + "`kubectl describe` for the story of one object, `kubectl get events` for the Namespace.", + ], + "fast": """ +k top pod -A --sort-by=memory; k top pod --containers; k top node +k get pods -o wide --sort-by=.spec.nodeName +k get pods -o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image +k get pod POD -o jsonpath='{.status.phase}' +k get pods -l tier=web --field-selector=status.phase!=Running -o name +k get pods -A --sort-by='.status.containerStatuses[0].restartCount' +k get events --sort-by=.lastTimestamp; k events --types=Warning --for pod/POD +""", + "traps": [ + "`kubectl get --sort-by` sorts ascending (the largest is last); `kubectl top --sort-by` sorts descending (the largest is first).", + "`kubectl top` shows usage, not requests or limits, and needs about a minute after a Pod starts.", + "Quote jsonpath for the shell: '{.items[*].metadata.name}'.", + ], + "docs": ["https://kubernetes.io/docs/reference/kubectl/quick-reference/", + "https://kubernetes.io/docs/reference/kubectl/jsonpath/"], +}, +{ + "id": "3.4", "key": "logs", "domain": "OBS", + "text": "Utilize container logs", + "tasks": [24, 64, 49, 54, 39], + "know": [ + "`kubectl logs` shows what a container wrote to stdout and stderr. The kubelet keeps it on the node, and it goes when the Pod is deleted.", + "Pick the container with -c (init containers too), or take --all-containers; --previous is the run before the last restart.", + "Narrow with --tail=N, --since=5m, --timestamps; follow with -f; by label with -l.", + ], + "fast": """ +k logs POD -c CONTAINER # --all-containers --prefix +k logs POD --previous # the crashed run +k logs POD --tail=20 --since=10m --timestamps +k logs -l app=web --prefix --tail=5 # every Pod with the label +k logs deploy/web; k logs job/render # one Pod of it +k logs POD | grep ERROR > errors.log +""", + "traps": [ + "With -l, only the last 10 lines of each Pod are shown unless you set --tail (--tail=-1 for everything).", + "`kubectl logs deploy/x` reads one Pod, not all replicas.", + "--previous fails when the container has never restarted.", + ], + "docs": ["https://kubernetes.io/docs/concepts/cluster-administration/logging/", + "https://kubernetes.io/docs/reference/kubectl/quick-reference/"], +}, +{ + "id": "3.5", "key": "debugging", "domain": "OBS", + "text": "Debugging in Kubernetes", + "tasks": [8, 25, 26, 36, 47, 48], + "know": [ + "Always the same order: get (status), describe (Events at the bottom), logs (--previous), then exec or debug.", + "Pending: nothing can schedule it (resources, taints, nodeSelector, an unbound claim). ImagePullBackOff / ErrImagePull: wrong image or tag. CreateContainerConfigError: a ConfigMap, Secret or key it references is missing.", + "CrashLoopBackOff: the process exits - read the previous log and lastState.terminated (reason, exitCode). OOMKilled (137): the memory limit is too low.", + "A Deployment with no Pods at all: describe its ReplicaSet - a quota, a LimitRange or Pod Security admission is rejecting them.", + ], + "fast": """ +k get pods -o wide; k describe pod POD | tail -20 +k get pod POD -o jsonpath='{.status.containerStatuses[0].state.waiting.reason}' +k get pod POD -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}' # .exitCode +k logs POD --previous; k exec -it POD -- sh +k debug -it POD --image=busybox:1.36 --target=CONTAINER # ephemeral container, sees its processes +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://SVC:PORT +k describe rs -l app=web | tail; k get events --sort-by=.lastTimestamp +k get pod POD -o yaml > pod.yaml && vi pod.yaml && k replace --force -f pod.yaml +""", + "traps": [ + "Most of a running Pod is immutable (the image is not): fix a bare Pod with `replace --force`, fix a Deployment's Pods in the Deployment.", + "Fix what the task names and nothing else - graders check that the ConfigMap, Service or Namespace you were told to leave is unchanged.", + "After fixing a Deployment wait for the rollout; old Pods linger in Terminating.", + ], + "docs": ["https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/", + "https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/", + "https://kubernetes.io/docs/tasks/debug/debug-application/determine-reason-pod-failure/"], +}, +# -------------- 4. Application Environment, Configuration and Security (25%) ---- +{ + "id": "4.1", "key": "crds", "domain": "ENV", + "text": "Discover and use resources that extend Kubernetes (CRD, Operators)", + "tasks": [27, 59, 58], + "know": [ + "A CustomResourceDefinition adds a kind to the API; its objects (custom resources) work with every kubectl verb. An operator is a CRD plus a controller acting on those objects.", + "Discover: `k get crd`, `k api-resources --api-group=GROUP` (kind, short names, namespaced or not), `k explain KIND.spec`.", + "The CRD's schema validates custom resources: required fields, enum values, minimum and maximum. The controller reports back in `.status`.", + ], + "fast": """ +k get crd | grep -i backup # the CRD's name is PLURAL.GROUP +k api-resources --api-group=data.example.com +k explain backup.spec # --recursive for the whole tree +k get backups -A; k get bkp NAME -o yaml; k describe backup NAME +k get crd backups.data.example.com -o yaml | less # served versions, schema, enum, maximum +k apply -f cr.yaml --dry-run=server # every validation error, nothing created +""", + "traps": [ + "A custom resource's apiVersion is the CRD's GROUP/VERSION, never apiextensions.k8s.io/v1.", + "Field names come from the CRD's schema - check with `kubectl explain` instead of guessing.", + "Deleting a CRD deletes every object of that kind.", + ], + "docs": ["https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/", + "https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/", + "https://kubernetes.io/docs/concepts/extend-kubernetes/operator/"], +}, +{ + "id": "4.2", "key": "access", "domain": "ENV", + "text": "Understand authentication, authorization and admission control", + "tasks": [28, 29, 49, 53], + "know": [ + "Every request passes authentication (who is it: a user certificate or token, a ServiceAccount token), authorization (may they: RBAC), then admission control (change or reject it: LimitRanger, ResourceQuota, PodSecurity).", + "RBAC: a Role or ClusterRole lists verbs on resources (and subresources such as pods/log, deployments/scale); a RoleBinding or ClusterRoleBinding grants it to users, groups or ServiceAccounts.", + "Pod Security admission: Namespace labels pod-security.kubernetes.io/enforce|warn|audit = privileged | baseline | restricted.", + "`Forbidden: User ... cannot list` is authorization. `violates PodSecurity`, `exceeded quota` and `maximum memory usage` are admission.", + ], + "fast": """ +k create role ci --verb=get,list,watch --resource=pods,pods/log -n NS +k create role ci --verb=get,update,patch --resource=deployments.apps,deployments.apps/scale -n NS +k create rolebinding ci --role=ci --serviceaccount=NS:SA -n NS # --user=jane --clusterrole=view +k auth can-i list pods -n NS --as=system:serviceaccount:NS:SA +k auth can-i --list -n NS --as=system:serviceaccount:NS:SA +k get ns NS --show-labels +# what `restricted` wants from every container: +securityContext: {allowPrivilegeEscalation: false, runAsNonRoot: true, capabilities: {drop: [ALL]}, + seccompProfile: {type: RuntimeDefault}} +""", + "traps": [ + "The same ServiceAccount is `--serviceaccount=NS:NAME` in a binding and `system:serviceaccount:NS:NAME` after `--as`.", + "`can-i create pods/exec` asks about a Pod named exec; use `can-i create pods --subresource=exec`.", + "Admission rejects Pods, not Deployments: the Deployment exists and shows 0 Pods, and the reason is in its ReplicaSet's events.", + ], + "docs": ["https://kubernetes.io/docs/reference/access-authn-authz/rbac/", + "https://kubernetes.io/docs/concepts/security/pod-security-admission/", + "https://kubernetes.io/docs/concepts/security/pod-security-standards/"], +}, +{ + "id": "4.3", "key": "quotas", "domain": "ENV", + "text": "Understand requests, limits, quotas", + "tasks": [30, 26, 53], + "know": [ + "Requests are what the scheduler reserves on a node. Limits are the ceiling: CPU above it is throttled, memory above it gets the container OOMKilled.", + "A ResourceQuota caps a Namespace's totals (requests.cpu, limits.memory, pods, count/...). Once it covers CPU or memory, every new Pod must state them.", + "A LimitRange gives containers a default request and limit and enforces a min and max per container, at admission.", + ], + "fast": """ +k create quota compute-quota -n NS --hard=requests.cpu=1,requests.memory=1Gi,limits.cpu=2,limits.memory=2Gi,pods=6 +k describe quota -n NS # Used against Hard +k describe limitrange -n NS +apiVersion: v1 +kind: LimitRange +metadata: {name: container-defaults} +spec: + limits: + - type: Container + defaultRequest: {cpu: 100m, memory: 128Mi} # the request a container gets + default: {cpu: 250m, memory: 256Mi} # the LIMIT a container gets + max: {cpu: 500m, memory: 512Mi} +""", + "traps": [ + "`default` is the default limit and `defaultRequest` the default request - they are easy to swap.", + "Quotas and LimitRanges only act on Pods created afterwards.", + "The rejection is not on the Deployment: `k describe rs` shows `exceeded quota` or `maximum memory usage per Container`.", + ], + "docs": ["https://kubernetes.io/docs/concepts/policy/resource-quotas/", + "https://kubernetes.io/docs/concepts/policy/limit-range/"], +}, +{ + "id": "4.4", "key": "configmaps", "domain": "ENV", + "text": "Understand ConfigMaps", + "tasks": [32, 10, 25, 51, 45], + "know": [ + "Create from literals, files, directories or env files. Each key becomes an environment variable (configMapKeyRef, envFrom) or a file (a configMap volume; `items` picks keys).", + "Mounted files follow a changed ConfigMap after a short delay. Environment variables and subPath mounts never do - restart the Pods.", + "A Pod that references a missing ConfigMap or key waits in CreateContainerConfigError unless the reference is `optional: true`.", + ], + "fast": """ +k create cm app --from-literal=TIER=backend --from-file=app.properties # --from-file=KEY=path --from-env-file=x.env +env: + - {name: APP_TIER, valueFrom: {configMapKeyRef: {name: app, key: TIER}}} +envFrom: [{configMapRef: {name: app}}] +volumes: [{name: cfg, configMap: {name: app, items: [{key: app.properties, path: app.properties}]}}] +volumeMounts: [{name: cfg, mountPath: /etc/app}] # one file into a full directory: add subPath: app.properties +k set env deploy/web --from=configmap/app; k exec POD -- env | grep TIER +k rollout restart deploy/web # pick up a change +""", + "traps": [ + "`--from-file` makes one key named after the file; `--from-env-file` makes one key per KEY=VALUE line.", + "A volume mounted on a directory hides what the image had there.", + "`immutable: true` cannot be edited or undone - delete the ConfigMap and create it again.", + ], + "docs": ["https://kubernetes.io/docs/concepts/configuration/configmap/", + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/"], +}, +{ + "id": "4.5", "key": "resources", "domain": "ENV", + "text": "Define resource requirements", + "tasks": [31, 26, 15, 53], + "know": [ + "Per container: resources.requests and resources.limits for cpu (cores or millicores: 250m) and memory (Mi, Gi).", + "QoS class: Guaranteed (every container has requests equal to limits for both CPU and memory), Burstable (some are set), BestEffort (none). BestEffort is evicted first.", + "A container with a limit but no request gets a request equal to the limit.", + ], + "fast": """ +k set resources deploy/web -c app --requests=cpu=250m,memory=128Mi --limits=cpu=250m,memory=128Mi +resources: + requests: {cpu: 100m, memory: 64Mi} + limits: {cpu: 250m, memory: 128Mi} +k get pod POD -o jsonpath='{.status.qosClass}' +k describe node NODE | grep -A8 'Allocated resources' +""", + "traps": [ + "`kubectl run` has no --requests or --limits any more: generate the YAML, or use `kubectl set resources` on a workload.", + "Guaranteed needs every container of the Pod, sidecars included.", + "Memory is Mi, not m: `128m` is a fraction of a byte.", + "A request no node can fit leaves the Pod Pending with `Insufficient cpu` or `Insufficient memory`.", + ], + "docs": ["https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + "https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/"], +}, +{ + "id": "4.6", "key": "secrets", "domain": "ENV", + "text": "Create & consume Secrets", + "tasks": [33, 10, 37, 50], + "know": [ + "A Secret's `data` is base64 - an encoding, not encryption. `stringData` takes plain text when you write one.", + "Types: Opaque (generic), kubernetes.io/tls, kubernetes.io/dockerconfigjson, kubernetes.io/service-account-token.", + "Consumed like a ConfigMap: secretKeyRef, envFrom secretRef, or a secret volume (`secretName`, `defaultMode`, `items`).", + ], + "fast": """ +k create secret generic api --from-literal=username=svc --from-literal=password='S3cr3t!' +k create secret tls web-tls --cert=tls.crt --key=tls.key +k create secret docker-registry regcred --docker-server=REG --docker-username=U --docker-password=P +k get secret api -o jsonpath='{.data.password}' | base64 -d +env: [{name: API_USER, valueFrom: {secretKeyRef: {name: api, key: username}}}] +volumes: [{name: creds, secret: {secretName: api, defaultMode: 0400}}] +volumeMounts: [{name: creds, mountPath: /etc/creds, readOnly: true}] +""", + "traps": [ + "A secret volume names it with `secretName`; a configMap volume uses `name`.", + "Encoding by hand: `echo -n value | base64`, or the newline becomes part of the secret.", + "defaultMode is octal in YAML (0400) and shows as decimal in JSON (256).", + ], + "docs": ["https://kubernetes.io/docs/concepts/configuration/secret/", + "https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/"], +}, +{ + "id": "4.7", "key": "serviceaccounts", "domain": "ENV", + "text": "Understand ServiceAccounts", + "tasks": [34, 28, 10, 49], + "know": [ + "Every Pod runs as a ServiceAccount (`default` if you name none); its short-lived token is mounted at /var/run/secrets/kubernetes.io/serviceaccount.", + "Choose it with spec.serviceAccountName. Stop the mount with `automountServiceAccountToken: false` on the ServiceAccount or on the Pod (the Pod wins).", + "Tokens: `kubectl create token` for a short-lived one; a Secret of type kubernetes.io/service-account-token annotated with the account's name for a long-lived one.", + "A ServiceAccount can do nothing until RBAC binds a Role to it.", + ], + "fast": """ +k create sa ci -n NS +k set serviceaccount deploy/web ci -n NS # = spec.template.spec.serviceAccountName +k patch sa ci -n NS -p '{"automountServiceAccountToken": false}' +k create token ci -n NS --duration=1h +k auth can-i list pods -n NS --as=system:serviceaccount:NS:ci +apiVersion: v1 +kind: Secret +metadata: {name: ci-token, annotations: {kubernetes.io/service-account.name: ci}} +type: kubernetes.io/service-account-token +""", + "traps": [ + "No token Secret is made for a new ServiceAccount (since 1.24) - create it yourself when a task wants one.", + "serviceAccountName cannot be changed on a running Pod; on a Deployment it starts a rollout.", + "A Pod naming a ServiceAccount that does not exist is never created - see the ReplicaSet's events.", + ], + "docs": ["https://kubernetes.io/docs/concepts/security/service-accounts/", + "https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/"], +}, +{ + "id": "4.8", "key": "security", "domain": "ENV", + "text": "Understand Application Security (SecurityContexts, Capabilities, etc.)", + "tasks": [9, 29, 44, 52], + "know": [ + "Pod level (spec.securityContext): runAsUser, runAsGroup, runAsNonRoot, fsGroup, supplementalGroups, seccompProfile - for every container.", + "Container level adds allowPrivilegeEscalation, privileged, readOnlyRootFilesystem and capabilities, and overrides the Pod level.", + "Capabilities are written without the CAP_ prefix: NET_BIND_SERVICE, NET_ADMIN, SYS_TIME. Drop ALL, then add back what the app needs.", + ], + "fast": """ +spec: + securityContext: {runAsUser: 1000, runAsGroup: 1000, runAsNonRoot: true, fsGroup: 2000} + containers: + - name: app + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL], add: [NET_BIND_SERVICE]} +k exec POD -- id +k explain pod.spec.securityContext; k explain pod.spec.containers.securityContext +""", + "traps": [ + "capabilities exist only on a container, fsGroup only on the Pod.", + "runAsNonRoot with an image that runs as root and no runAsUser: CreateContainerConfigError.", + "readOnlyRootFilesystem breaks an app that writes to /tmp or /var/cache - mount an emptyDir there.", + ], + "docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/security-context/"], +}, +# -------------------------------------------------- 5. Services and Networking (20%) ---- +{ + "id": "5.1", "key": "netpol", "domain": "NET", + "text": "Demonstrate basic understanding of NetworkPolicies", + "tasks": [12, 35, 55], + "know": [ + "Pods accept and send everything until a NetworkPolicy selects them. From then on, for the policyTypes it lists, only what some policy allows gets through.", + "Policies only allow, and they add up: there is no deny rule and no ordering.", + "Peers: podSelector (same Namespace), namespaceSelector, ipBlock. Two selectors in one list item mean AND; two list items mean OR.", + "Default deny: `podSelector: {}` with a policyType and no rules. Locking down egress also needs DNS allowed (UDP and TCP 53).", + ], + "fast": """ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: db-allow, namespace: NS} +spec: + podSelector: {matchLabels: {app: db}} + policyTypes: [Ingress] + ingress: + - from: + - podSelector: {matchLabels: {app: api}} # OR + - namespaceSelector: {matchLabels: {purpose: monitoring}} + ports: [{protocol: TCP, port: 5432}] +# egress that keeps DNS: egress: [{ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]}, ...] +k describe netpol db-allow -n NS +k exec CLIENT -- wget -qO- -T2 http://db:5432 # test it with real traffic +""", + "traps": [ + "There is no `kubectl create networkpolicy` - copy the skeleton from the docs.", + "`- podSelector: ...` followed by `namespaceSelector:` in the same item is AND; a second `- namespaceSelector:` is OR.", + "A missing or empty `ingress` denies everything; `ingress: [{}]` allows everything.", + "The policy lives in the Namespace of the Pods it selects, and a Namespace is matched by its labels (kubernetes.io/metadata.name=NAME always exists).", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/network-policies/"], +}, +{ + "id": "5.2", "key": "services", "domain": "NET", + "text": "Provide and troubleshoot access to applications via services", + "tasks": [11, 36, 54, 56], + "know": [ + "A Service selects Pods by label and forwards `port` to `targetPort` (a number, or the name of a container port).", + "Types: ClusterIP (inside the cluster), NodePort (30000-32767 on every node), LoadBalancer, ExternalName; headless (`clusterIP: None`) gives each Pod its own DNS record.", + "DNS names: SVC, SVC.NS, SVC.NS.svc.cluster.local.", + "Troubleshoot in this order: are there endpoints, does the selector match the Pod labels, are the Pods Ready, is targetPort the port the container listens on.", + ], + "fast": """ +k expose deploy web --port=8080 --target-port=80 # --name=web-svc --type=NodePort +k create svc nodeport web-ext --tcp=80:80 --node-port=30080 +k get svc,endpointslices -o wide; k describe svc web # Endpoints: = nothing selected +k get pods -l app=web --show-labels +k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://web:8080 +k port-forward svc/web 8080:80 +""", + "traps": [ + "`kubectl expose` copies the workload's selector; `kubectl create svc` selects app=, which may match nothing.", + "`expose` cannot choose the nodePort - use `create svc nodeport --node-port` or edit the YAML.", + "A Service without endpoints still resolves in DNS; the connection is what fails.", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/service/", + "https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/", + "https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/"], +}, +{ + "id": "5.3", "key": "ingress", "domain": "NET", + "text": "Use Ingress rules to expose applications", + "tasks": [37, 11, 57], + "know": [ + "An Ingress routes HTTP(S) by host and path to Services of its own Namespace; an ingress controller, chosen with ingressClassName, does the work.", + "Each path has a pathType (Prefix, Exact or ImplementationSpecific) and a backend: service.name plus service.port.number or .name.", + "TLS: spec.tls lists hosts and the kubernetes.io/tls Secret that holds their certificate.", + ], + "fast": """ +k create ingress shop --class=nginx \\ + --rule="shop.example.com/api*=api:8080,tls=shop-tls" --rule="shop.example.com/*=web:80,tls=shop-tls" +k create secret tls shop-tls --cert=tls.crt --key=tls.key +k get ingress,ingressclass; k describe ingress shop # a missing backend shows as +k explain ingress.spec.rules.http.paths +""", + "traps": [ + "In --rule a trailing * means pathType Prefix; without it the path is Exact.", + "The backend port is the Service's port, not the container's.", + "No ingress controller runs in this simulator, so Ingress tasks are graded on the object - on the exam, curl it.", + ], + "docs": ["https://kubernetes.io/docs/concepts/services-networking/ingress/"], +}, +]