diff --git a/README.md b/README.md index fd74ad0..6c26cc2 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Entry point is `./exam` (renamed from `cka-sim` so it can't collide with the dir Hands-on exam tasks graded **per check** against a real cluster, an exam clock, a browser front end modelled on the real PSI exam, attempt history, and drills that rotate through the -domains before you sit a full exam. Tracks: **CKA** (136 tasks: a random pool of 43 normal + -24 hard, plus four fixed simulator sets A–D of 17–18 tasks each), CKAD and CKS (scaffolded — same -format, add questions when you get there). +domains before you sit a full exam. Tracks: **CKA** (102 tasks: a random pool of 43 normal + +24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), CKAD and CKS (scaffolded — +same format, add questions when you get there). ## How a session is built @@ -68,7 +68,7 @@ The same state is available from the terminal if you prefer: ./exam drill # next least-drilled domain, 5 tasks (add --hard) ./exam drill weak | drill 2 17 20 | drill TSHOOT NET | drill setd ./exam start [--hard] # full exam (gated until all domains drilled; --force) -./exam sim a|b|c|d # a fixed 17/18-task simulator set, exam rules, 120 min +./exam sim c|d # a fixed 18/17-task simulator set, exam rules, 120 min ./exam study [DOMAIN|weak|N..] # no clock, solutions visible, nothing recorded ./exam list | show N | timer | status # during ./exam end # submit @@ -133,12 +133,12 @@ Before every session a baseline pass repairs anything a previous, unfinished ses The kindnet CNI here **enforces** NetworkPolicy (it embeds a policy engine), so a wrong policy really drops traffic; no ingress or Gateway controller runs, so those are graded on the objects. -**Simulator sets** (`./exam sim a|b|c|d`, never drawn into random sessions): A and B are -killer.sh-grade investigate-and-write-to-file sets, C is modelled on an 18-question commercial -mock sitting, and D is killer.sh-style — control-plane inventory, event forensics with crictl, -a broken kubelet unit, kubelet certificates, manual scheduling with the scheduler stopped, Helm -housekeeping with a stuck release, and an enforced AND-vs-OR NetworkPolicy. All are original -scenarios, not copies of any commercial simulator. +**Simulator sets** (`./exam sim c|d`, never drawn into random sessions): C is modelled on an +18-question commercial mock sitting, and D is killer.sh-style — control-plane inventory, event +forensics with crictl, a broken kubelet unit, kubelet certificates, manual scheduling with the +scheduler stopped, Helm housekeeping with a stuck release, and an enforced AND-vs-OR +NetworkPolicy. Both are original scenarios, not copies of any commercial simulator. (Sets A and B +were retired in favour of these two; their last versions are in git history.) ## Adding tasks or a new track diff --git a/exam b/exam index a9c204e..1ca62b1 100755 --- a/exam +++ b/exam @@ -212,8 +212,8 @@ def q_by_id(n: int) -> dict[str, Any]: for q in QUESTIONS: if q["id"] == n: return q - # ids are not contiguous (1-43 base, 101-124 hard, 201-217 / 301-317 / 401-418 / 501-517 sim - # sets), so print the real ranges rather than "1-", which named ids that do not exist. + # ids are not contiguous (1-43 base, 101-124 hard, 401-418 / 501-517 sim sets), so print the + # real ranges rather than "1-", which named ids that do not exist. ids = sorted(q["id"] for q in QUESTIONS) ranges, start, prev = [], ids[0], ids[0] for i in ids[1:] + [None]: @@ -375,7 +375,7 @@ def drilled_domains(): def breaks_of(q): """A task's "breaks" as a set. It is a string or a list: Q118 damages the scheduler AND the - controller-manager, and declaring only one let it share a session with Q38/Q317.""" + controller-manager, and declaring only one let it share a session with Q38.""" b = q.get("breaks") return set() if not b else ({b} if isinstance(b, str) else set(b)) @@ -653,11 +653,10 @@ f=/etc/kubernetes/manifests/kube-scheduler.yaml sed -i "s|^\( *\)- kube-schedulerr$|\1- kube-scheduler|" $f 2>/dev/null ' /dev/null @@ -711,9 +710,10 @@ for n in "$W1" "$W2" "$W3"; do ' /dev/null 2>&1; then fi # 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. +# dedicated/team/workload and Q107 from pool/lane; `maintenance` was the retired set B's and may +# still be on a cluster that ran it. Miss one and that node silently stops accepting pods. # One key per call: `kubectl taint node N a- b-` aborts on the first key that is not present and # removes NOTHING, so the single taint that is actually set survives the "cleanup" and W1 keeps # rejecting pods in every later session - the exact failure this block exists to prevent. @@ -750,9 +750,8 @@ if $K -n kube-system get deploy metrics-server >/dev/null 2>&1 \ -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' >/dev/null 2>&1 || true fi -# 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). +# cordons: Q117 cordons W2, and a drain task left half-done leaves a node cordoned. A cordon left +# behind fails any later task that needs every worker (Q26's DaemonSet coverage, Q107's spread). for n in "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done # Deliberately NOT restored, and why: @@ -763,10 +762,6 @@ for n in "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done # to tell "mid-attempt" from "correctly completed" without guessing, and guessing wrong here # rewrites etcd's data-dir or re-issues certs on a healthy cluster. Re-run ./setup.sh (or # ./teardown.sh && ./setup.sh) if one of those leaves the control plane wedged. -# -# proxy-rules (Q302) needs nothing: its setup only creates a namespace and deletes a pod and -# service. The "breaks" key is there for no_conflicts() bookkeeping, not because it damages -# shared state. """ def restore_baseline(log: Callable[..., Any] = _log_msg): @@ -904,8 +899,11 @@ def cmd_drill(args): ids += [q["id"] for q in set_questions(a.lower().replace("set", ""))] elif a.upper() in DOMAINS: ids += [q["id"] for q in QUESTIONS if q["domain"] == a.upper()] - else: + elif a.isdigit(): ids.append(q_by_id(int(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'}).") ids = sorted(set(ids)) qs, solo = split_solo([q for q in QUESTIONS if q["id"] in ids]) if solo: diff --git a/tracks/cka/bank_extra.py b/tracks/cka/bank_extra.py index 5278a5c..c67a8c8 100644 --- a/tracks/cka/bank_extra.py +++ b/tracks/cka/bank_extra.py @@ -282,8 +282,8 @@ $K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null }, { "id": 26, "domain": "WORK", "title": "DaemonSet on every node", - # needs every node schedulable: Q25/Q107 taint W1 and must keep the taint, Q307 blocks it - "breaks": ["w1-taint", "w1-block"], + # needs every node schedulable: Q25/Q107 taint W1 and must keep the taint + "breaks": ["w1-taint"], "params": lambda r: {"ns": ns_pick(r, "agents"), "ds": name_pick(r, "node-agent")}, "task": """ Create a DaemonSet `%{ds}` in namespace `%{ns}` (image `busybox:1.36`, command `sleep 3600`) diff --git a/tracks/cka/bank_hard.py b/tracks/cka/bank_hard.py index d5a39a0..f44ad3b 100644 --- a/tracks/cka/bank_hard.py +++ b/tracks/cka/bank_hard.py @@ -670,7 +670,7 @@ echo "$pv" > "$WS/.q116-pv" }, # ============================================================== TSHOOT ==== { - "id": 117, "domain": "TSHOOT", "breaks": ["w2-kubelet", "w2-drain"], "difficulty": "hard", "title": "Worker node: three things wrong", "disruptive": True, + "id": 117, "domain": "TSHOOT", "breaks": "w2-kubelet", "difficulty": "hard", "title": "Worker node: three things wrong", "disruptive": True, "params": lambda r: {"ctx": "k8s-c1-H"}, "task": CTX + """ Node `{W2}` is unusable. Make it fully usable again: `Ready`, schedulable, kubelet running and diff --git a/tracks/cka/bank_sim_a.py b/tracks/cka/bank_sim_a.py deleted file mode 100644 index 685720b..0000000 --- a/tracks/cka/bank_sim_a.py +++ /dev/null @@ -1,1078 +0,0 @@ -"""Simulator Set A - 17 tasks, fixed set, killer.sh-grade difficulty. - -Original scenarios (not copies of any commercial simulator) exercising the same techniques: -investigate-and-write-to-file, node-level work via crictl/openssl/systemd, Kustomize overlays, -Helm with CRDs, QoS reasoning, Gateway API conversion, NetworkPolicy match semantics, CoreDNS. - -Run with: ./exam sim a -""" -from tracks.cka.common import C, NS - -Q = [] - - -def task(**kw): - kw.setdefault("difficulty", "hard") - kw["set"] = "a" - Q.append(kw) - - -# ------------------------------------------------------------------ 201 ---- -task( - id=201, domain="ARCH", title="kubeconfig forensics", - task=""" -The file `{WS}/q201/kubeconfig` was handed over by another team. - -1. Write every context name, one per line, to `{WS}/q201/contexts`. -2. Write the name of the current context to `{WS}/q201/current-context`. -3. The user `svc-reporting` has an embedded client certificate. Write it **base64-decoded** - (the PEM block) to `{WS}/q201/cert`. -4. Write the Common Name (CN) of that certificate to `{WS}/q201/cn`. - -Do not modify the kubeconfig. -""", - setup=r""" -rm -rf "$WS/q201"; mkdir -p "$WS/q201"; cd "$WS/q201" -openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=svc-reporting/O=reporting" -keyout r.key -out r.crt >/dev/null 2>&1 -openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=svc-billing/O=billing" -keyout b.key -out b.crt >/dev/null 2>&1 -CA=$(base64 -w0 r.crt); RC=$(base64 -w0 r.crt); RK=$(base64 -w0 r.key); BC=$(base64 -w0 b.crt); BK=$(base64 -w0 b.key) -cat > kubeconfig </dev/null | sort | tr '\n' ' ')" = "prod-billing prod-reporting staging-reporting " ]"""), - C("current-context file is correct", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q201/current-context" 2>/dev/null)" prod-billing"""), - C("cert file is the decoded PEM of svc-reporting", 3, r""" -grep -q 'BEGIN CERTIFICATE' "$WS/q201/cert" 2>/dev/null || exit 1 -a=$(openssl x509 -in "$WS/q201/cert" -noout -fingerprint -sha256 2>/dev/null); b=$(openssl x509 -in "$WS/q201/.expected.crt" -noout -fingerprint -sha256) -eq "$a" "$b" """), - C("cn file contains svc-reporting", 1, r"""grep -q 'svc-reporting' "$WS/q201/cn" 2>/dev/null"""), - ], - solution=""" -export KC={WS}/q201/kubeconfig -k --kubeconfig $KC config get-contexts -o name > {WS}/q201/contexts -k --kubeconfig $KC config current-context > {WS}/q201/current-context -k --kubeconfig $KC config view --raw \\ - -o jsonpath='{.users[?(@.name=="svc-reporting")].user.client-certificate-data}' | base64 -d > {WS}/q201/cert -openssl x509 -in {WS}/q201/cert -noout -subject | sed 's/.*CN *= *//' > {WS}/q201/cn -# --raw is what reveals the data fields; without it kubectl prints REDACTED -""", -) - -# ------------------------------------------------------------------ 202 ---- -task( - id=202, domain="ARCH", title="Helm chart with CRDs, then a custom resource", - task=""" -A local Helm chart is at `{WS}/q202/chart`. It ships a CRD (`Ledger`, group `finance.example.com`). - -1. Create Namespace `finance`. -2. Install the chart into `finance` as release `ledger-operator`, with value `watchAllNamespaces=true`. -3. The file `{WS}/q202/ledger.yaml` creates a `Ledger`. Add `spec.retentionDays: 30` to it - (the CRD's schema accepts it — check with `kubectl explain`), then apply it. - -The release must report `deployed` and `kubectl get ledger -A` must list it. -""", - setup=r""" -rm -rf "$WS/q202"; mkdir -p "$WS/q202/chart/crds" "$WS/q202/chart/templates" -cat > "$WS/q202/chart/Chart.yaml" <<'EOF' -apiVersion: v2 -name: ledger-operator -version: 0.2.0 -appVersion: "0.2.0" -description: Toy operator shipping a Ledger CRD -EOF -cat > "$WS/q202/chart/values.yaml" <<'EOF' -watchAllNamespaces: false -replicaCount: 1 -EOF -cat > "$WS/q202/chart/crds/ledger-crd.yaml" <<'EOF' -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: {name: ledgers.finance.example.com} -spec: - group: finance.example.com - scope: Namespaced - names: {kind: Ledger, plural: ledgers, singular: ledger, shortNames: [ldg]} - versions: - - name: v1 - served: true - storage: true - schema: - openAPIV3Schema: - type: object - properties: - spec: - type: object - properties: - currency: {type: string, description: "ISO currency code"} - retentionDays: {type: integer, description: "How many days entries are kept"} -EOF -cat > "$WS/q202/chart/templates/deployment.yaml" <<'EOF' -apiVersion: apps/v1 -kind: Deployment -metadata: {name: {{ .Release.Name }}} -spec: - replicas: {{ .Values.replicaCount }} - selector: {matchLabels: {app: {{ .Release.Name }}}} - template: - metadata: {labels: {app: {{ .Release.Name }}}} - spec: - containers: - - name: operator - image: busybox:1.36 - command: [sleep, "86400"] - env: - - {name: WATCH_ALL_NAMESPACES, value: "{{ .Values.watchAllNamespaces }}"} -EOF -cat > "$WS/q202/ledger.yaml" <<'EOF' -apiVersion: finance.example.com/v1 -kind: Ledger -metadata: - name: main-ledger - namespace: finance -spec: - currency: USD -EOF -helm -n finance uninstall ledger-operator >/dev/null 2>&1 || true -$K delete crd ledgers.finance.example.com --ignore-not-found >/dev/null -$K delete ns finance --ignore-not-found --wait=false >/dev/null -command -v helm >/dev/null || echo "warn: helm not installed - this task needs it" -""", - checks=[ - C("release ledger-operator deployed in finance", 2, r"""helm -n finance status ledger-operator -o json 2>/dev/null | python3 -c 'import sys,json;sys.exit(0 if json.load(sys.stdin)["info"]["status"]=="deployed" else 1)'"""), - C("watchAllNamespaces=true reached the pod env", 2, r"""eq "$(j -n finance deploy ledger-operator '{.spec.template.spec.containers[0].env[?(@.name=="WATCH_ALL_NAMESPACES")].value}')" true"""), - C("CRD installed and shortname works", 1, r"""$K get crd ledgers.finance.example.com >/dev/null 2>&1 && $K -n finance get ldg >/dev/null 2>&1"""), - C("Ledger main-ledger has currency USD and retentionDays 30", 3, r"""eq "$(j -n finance ledgers main-ledger '{.spec.currency}')" USD && eq "$(j -n finance ledgers main-ledger '{.spec.retentionDays}')" 30"""), - ], - solution=""" -k create ns finance -helm -n finance install ledger-operator {WS}/q202/chart --set watchAllNamespaces=true -helm -n finance ls; k get crd | grep ledgers -k explain ledger.spec # confirms retentionDays exists -vi {WS}/q202/ledger.yaml # add: retentionDays: 30 under spec -k apply -f {WS}/q202/ledger.yaml -k -n finance get ldg -# CRDs in a chart's crds/ directory are installed by helm before the templates, -# which is why the Ledger can be created straight after install. -""", -) - -# ------------------------------------------------------------------ 203 ---- -task( - id=203, domain="WORK", title="Find the controller, then scale it", - task=""" -Namespace `project-atlas` runs Pods named `atlas-cache-*`. Management wants them down to **one** replica. - -1. Find out which resource manages them and write its kind and name, in the form `kind/name` - (lowercase kind, as `kubectl get` prints it), to `{WS}/q203/owner.txt`. -2. Scale it to 1 replica without deleting anything. -3. Write the number of Pods still running in the Namespace after scaling to `{WS}/q203/count.txt`. -""", - setup=NS + r""" -mkns project-atlas -rm -rf "$WS/q203"; mkdir -p "$WS/q203" -$K -n project-atlas delete sts atlas-cache --ignore-not-found >/dev/null -$K apply -f - >/dev/null <<'EOF' -apiVersion: v1 -kind: Service -metadata: {name: atlas-cache, namespace: project-atlas} -spec: {clusterIP: None, selector: {app: atlas-cache}, ports: [{port: 6379}]} ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: {name: atlas-cache, namespace: project-atlas} -spec: - serviceName: atlas-cache - replicas: 3 - selector: {matchLabels: {app: atlas-cache}} - template: - metadata: {labels: {app: atlas-cache}} - spec: - containers: [{name: cache, image: busybox:1.36, command: [sleep, "86400"]}] -EOF -""", - checks=[ - C("owner.txt identifies statefulset/atlas-cache", 2, r"""tr -d '[:space:]' < "$WS/q203/owner.txt" 2>/dev/null | grep -Eq '^(statefulset|statefulset.apps)/atlas-cache$'"""), - C("StatefulSet scaled to 1 and still exists", 3, r"""eq "$(j -n project-atlas sts atlas-cache '{.spec.replicas}')" 1 && eq "$(j -n project-atlas sts atlas-cache '{.status.readyReplicas}')" 1"""), - C("count.txt matches the running pod count", 1, r"""n=$($K -n project-atlas get pod --no-headers 2>/dev/null | grep -c Running); eq "$(tr -dc '0-9' < "$WS/q203/count.txt" 2>/dev/null)" "$n" """), - ], - solution=""" -k -n project-atlas get pod --show-labels -k -n project-atlas get deploy,sts,ds,rs # -> statefulset.apps/atlas-cache -# or from the pod itself: -k -n project-atlas get pod atlas-cache-0 -o jsonpath='{.metadata.ownerReferences[0].kind}{"/"}{.metadata.ownerReferences[0].name}' -echo statefulset/atlas-cache > {WS}/q203/owner.txt -k -n project-atlas scale sts atlas-cache --replicas 1 -k -n project-atlas get pod | grep -c Running > {WS}/q203/count.txt -""", -) - -# ------------------------------------------------------------------ 204 ---- -task( - id=204, domain="WORK", title="Which Pods get evicted first", - task=""" -Namespace `project-orion` is close to filling its nodes. - -1. Write the names of all Pods in `project-orion` that the kubelet would evict **first** under - node memory pressure — one name per line, sorted — to `{WS}/q204/evicted-first.txt`. -2. Write the Quality of Service class of Pod `orion-api-0` to `{WS}/q204/api-qos.txt`. -3. One Deployment's Pods are `Guaranteed`. Write its name to `{WS}/q204/guaranteed.txt`. -""", - setup=NS + r""" -mkns project-orion -rm -rf "$WS/q204"; mkdir -p "$WS/q204" -# the pod is re-created below under the same name: a delete that does not wait lets the apply land on -# the terminating old pod, which then vanishes and leaves the task without orion-api-0 -$K -n project-orion delete deploy --all >/dev/null 2>&1; $K -n project-orion delete pod orion-api-0 --ignore-not-found --force --grace-period=0 --wait=true >/dev/null 2>&1 -$K apply -f - >/dev/null </dev/null | python3 -c ' -import sys,json -items=json.load(sys.stdin)["items"]; got={l.strip() for l in open(sys.argv[1]) if l.strip()} -qos={p["metadata"]["name"]:p["status"].get("qosClass") for p in items} -seeded={p["metadata"]["name"] for p in items if p["metadata"].get("labels",{}).get("app")=="orion-batch"} -ok=seeded and seeded<=got and not any(qos.get(n) in ("Burstable","Guaranteed") for n in got) -sys.exit(0 if ok else 1)' "$f" """), - C("api-qos.txt says Burstable", 2, r"""grep -qi burstable "$WS/q204/api-qos.txt" 2>/dev/null"""), - C("guaranteed.txt names orion-ledger", 2, r"""grep -q 'orion-ledger' "$WS/q204/guaranteed.txt" 2>/dev/null"""), - ], - solution=""" -k -n project-orion get pod -o jsonpath='{range .items[*]}{.metadata.name} {.status.qosClass}{"\\n"}{end}' -# BestEffort (no requests and no limits at all) is evicted first, then Burstable -# exceeding its requests, Guaranteed (requests == limits on every container) last. -k -n project-orion get pod -o jsonpath='{range .items[*]}{.metadata.name} {.status.qosClass}{"\\n"}{end}' \\ - | awk '$2=="BestEffort"{print $1}' | sort > {WS}/q204/evicted-first.txt -k -n project-orion get pod orion-api-0 -o jsonpath='{.status.qosClass}' > {WS}/q204/api-qos.txt -echo orion-ledger > {WS}/q204/guaranteed.txt -""", -) - -# ------------------------------------------------------------------ 205 ---- -task( - id=205, domain="ARCH", title="Kustomize: drop a ConfigMap, add an HPA, override in prod", - task=""" -The app `checkout` is deployed from `{WS}/q205/checkout` into Namespaces `checkout-staging` -and `checkout-prod`: - -``` -kubectl kustomize {WS}/q205/checkout/staging | kubectl apply -f - -kubectl kustomize {WS}/q205/checkout/prod | kubectl apply -f - -``` - -Change the Kustomize config so that: -1. the ConfigMap `legacy-scaler-config` is gone from the built output of **both** overlays, -2. a HorizontalPodAutoscaler named `checkout` targets the `checkout` Deployment with - min 2 / max 5 replicas at 60% average CPU utilisation, -3. in **prod** the same HPA has max 8 replicas, -4. both overlays are applied, and the ConfigMap no longer exists in either Namespace. -""", - setup=r""" -rm -rf "$WS/q205"; mkdir -p "$WS/q205/checkout/base" "$WS/q205/checkout/staging" "$WS/q205/checkout/prod" -B="$WS/q205/checkout" -cat > "$B/base/kustomization.yaml" <<'EOF' -resources: [checkout.yaml] -EOF -cat > "$B/base/checkout.yaml" <<'EOF' -apiVersion: v1 -kind: ConfigMap -metadata: {name: legacy-scaler-config} -data: {threshold: "70"} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: {name: checkout} -spec: - replicas: 1 - selector: {matchLabels: {id: checkout}} - template: - metadata: {labels: {id: checkout}} - spec: - containers: - - name: web - image: httpd:2-alpine - resources: {requests: {cpu: 10m, memory: 20Mi}} -EOF -for env in staging prod; do -cat > "$B/$env/kustomization.yaml" < "$B/$env/checkout.yaml" </dev/null; done -$K kustomize "$B/staging" 2>/dev/null | $K apply -f - >/dev/null 2>&1 -$K kustomize "$B/prod" 2>/dev/null | $K apply -f - >/dev/null 2>&1 -""", - checks=[ - C("built output of both overlays contains no ConfigMap", 3, r""" -for e in staging prod; do $K kustomize "$WS/q205/checkout/$e" 2>/dev/null | grep -q 'kind: ConfigMap' && exit 1; done -$K kustomize "$WS/q205/checkout/staging" >/dev/null 2>&1"""), - C("HPA in staging: min 2, max 5, 60% CPU, targets the Deployment", 3, r""" -h="$K -n checkout-staging get hpa checkout" -eq "$($h -o jsonpath='{.spec.minReplicas}' 2>/dev/null)" 2 && eq "$($h -o jsonpath='{.spec.maxReplicas}')" 5 \ - && eq "$($h -o jsonpath='{.spec.scaleTargetRef.name}')" checkout \ - && $h -o jsonpath='{.spec.metrics[*].resource}' | grep -q '"averageUtilization":60'"""), - C("HPA in prod has max 8 (min and target unchanged)", 2, r""" -h="$K -n checkout-prod get hpa checkout" -eq "$($h -o jsonpath='{.spec.maxReplicas}' 2>/dev/null)" 8 && eq "$($h -o jsonpath='{.spec.minReplicas}')" 2"""), - C("legacy-scaler-config removed from both Namespaces", 2, r""" -! $K -n checkout-staging get cm legacy-scaler-config >/dev/null 2>&1 && ! $K -n checkout-prod get cm legacy-scaler-config >/dev/null 2>&1"""), - ], - solution=""" -cd {WS}/q205/checkout -# 1. the ConfigMap is patched by BOTH overlays, so removing it only from base breaks the build. -# Delete it from base/checkout.yaml AND from staging/checkout.yaml AND prod/checkout.yaml. -# 2. add the HPA to base/checkout.yaml (no namespace - the overlays set it): -# apiVersion: autoscaling/v2 -# kind: HorizontalPodAutoscaler -# metadata: {name: checkout} -# spec: -# scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: checkout} -# minReplicas: 2 -# maxReplicas: 5 -# metrics: [{type: Resource, resource: {name: cpu, target: {type: Utilization, averageUtilization: 60}}}] -# 3. in prod/checkout.yaml add a patch with only the changed field: -# apiVersion: autoscaling/v2 -# kind: HorizontalPodAutoscaler -# metadata: {name: checkout} -# spec: {maxReplicas: 8} -k kustomize staging | k apply -f - -k kustomize prod | k apply -f - -# 4. Kustomize keeps no state: it never deletes resources that only exist in the cluster. -k -n checkout-staging delete cm legacy-scaler-config -k -n checkout-prod delete cm legacy-scaler-config -""", -) - -# ------------------------------------------------------------------ 206 ---- -task( - id=206, domain="STOR", title="PV, PVC and a Deployment that mounts it", - task=""" -1. Create a PersistentVolume `orbit-pv`: 3Gi, accessMode `ReadWriteOnce`, `hostPath` `/Volumes/orbit`, - and **no** storageClassName. -2. In Namespace `project-orbit` create PVC `orbit-pvc` requesting 3Gi, `ReadWriteOnce`, also with - **no** storageClassName, bound to that PV. -3. Create Deployment `orbit` in the same Namespace, image `httpd:2-alpine`, that mounts the claim - at `/usr/local/apache2/htdocs/data`. - -The claim must be `Bound` and the Pod `Running`. -""", - setup=NS + r""" -mkns project-orbit -$K -n project-orbit delete deploy orbit --ignore-not-found >/dev/null -$K -n project-orbit delete pvc orbit-pvc --ignore-not-found >/dev/null -$K delete pv orbit-pv --ignore-not-found >/dev/null -""", - checks=[ - C("PV orbit-pv: 3Gi, RWO, hostPath /Volumes/orbit, no storageClassName", 3, r""" -p="$K get pv orbit-pv" -eq "$($p -o jsonpath='{.spec.capacity.storage}' 2>/dev/null)" 3Gi && eq "$($p -o jsonpath='{.spec.hostPath.path}')" /Volumes/orbit \ - && $p -o jsonpath='{.spec.accessModes}' | grep -q ReadWriteOnce && [ -z "$($p -o jsonpath='{.spec.storageClassName}')" ]"""), - C("PVC orbit-pvc Bound to orbit-pv with no storageClassName", 3, r""" -eq "$(j -n project-orbit pvc orbit-pvc '{.status.phase}')" Bound && eq "$(j -n project-orbit pvc orbit-pvc '{.spec.volumeName}')" orbit-pv \ - && [ -z "$(j -n project-orbit pvc orbit-pvc '{.spec.storageClassName}')" ]"""), - C("Deployment orbit available and mounts the claim at the right path", 3, r""" -eq "$(j -n project-orbit deploy orbit '{.status.availableReplicas}')" 1 \ - && [ -n "$(j -n project-orbit deploy orbit '{.spec.template.spec.volumes[?(@.persistentVolumeClaim.claimName=="orbit-pvc")].name}')" ] \ - && j -n project-orbit deploy orbit '{.spec.template.spec.containers[0].volumeMounts[*].mountPath}' | grep -q '/usr/local/apache2/htdocs/data'"""), - ], - solution=""" -cat < orbit.yaml -# add to the pod spec: -# volumes: [{name: data, persistentVolumeClaim: {claimName: orbit-pvc}}] -# and to the container: -# volumeMounts: [{name: data, mountPath: /usr/local/apache2/htdocs/data}] -k apply -f orbit.yaml -k -n project-orbit get pv,pvc -""", -) - -# ------------------------------------------------------------------ 207 ---- -task( - id=207, domain="TSHOOT", title="Resource usage scripts", - task=""" -Write two executable bash scripts that use `kubectl`: - -1. `{WS}/q207/usage-nodes.sh` — prints CPU and memory usage of all **nodes**, sorted by CPU - (highest first). -2. `{WS}/q207/usage-pods.sh` — prints CPU and memory usage of all **Pods in all Namespaces**, - broken down **per container**. - -Use full command names, no aliases. The scripts must be executable. -""", - setup=r"""rm -rf "$WS/q207"; mkdir -p "$WS/q207" """, - checks=[ - C("usage-nodes.sh is executable and uses kubectl top node with cpu sorting", 3, r""" -f="$WS/q207/usage-nodes.sh"; [ -x "$f" ] || exit 1 -grep -q 'kubectl top node' "$f" && grep -Eq -- '--sort-by[= ]?.?cpu' "$f" && ! grep -qE '(^| )k( |$)' "$f" """), - C("usage-pods.sh is executable, all namespaces, per container", 3, r""" -f="$WS/q207/usage-pods.sh"; [ -x "$f" ] || exit 1 -grep -q 'kubectl top pod' "$f" && grep -Eq -- '(-A|--all-namespaces)' "$f" && grep -q -- '--containers' "$f" """), - ], - solution=""" -cat > {WS}/q207/usage-nodes.sh <<'EOF' -kubectl top node --sort-by=cpu -EOF -cat > {WS}/q207/usage-pods.sh <<'EOF' -kubectl top pod --all-namespaces --containers=true -EOF -chmod +x {WS}/q207/usage-nodes.sh {WS}/q207/usage-pods.sh -# `kubectl top -h` shows both flags. Aliases (k) don't exist in a fresh shell, so write kubectl. -""", -) - -# ------------------------------------------------------------------ 208 ---- -# Own namespace (project-vega): it used to share project-atlas with 203, whose "running pods" -# count then dropped by one when this drain deleted the bare pod. And the grader checks the pods -# that were on the node when it was seeded, not "nothing on the node at grading time" - after the -# required uncordon the scheduler is free to put new pods there (212's one-per-node spread always -# does), which failed this task no matter what the candidate did. -task( - id=208, domain="ARCH", title="Drain a node for maintenance", breaks="w2-drain", - task=""" -Node `{W2}` needs kernel maintenance. - -1. Evict every non-DaemonSet Pod from it. Pods with `emptyDir` volumes must not stop you, - and neither must Pods that aren't managed by a controller. -2. Write the exact command you used to `{WS}/q208/drain.sh`. -3. Deployment `vega-web` in Namespace `project-vega` must still have all 3 replicas Available afterwards. -4. When you're done, make the node schedulable again. - -A Pod without a controller is **deleted** by a forced drain, not rescheduled. Before you drain, -look at what else runs on the node (`k get pods -A -o wide --field-selector spec.nodeName={W2}`) — -anything you created there for another task has to be recreated afterwards. -""", - setup=NS + r""" -mkns project-vega -rm -rf "$WS/q208"; mkdir -p "$WS/q208" -$K -n project-vega delete deploy vega-web --ignore-not-found >/dev/null -$K -n project-vega delete pod vega-orphan --ignore-not-found --force --grace-period=0 --wait=true >/dev/null 2>&1 -$K uncordon "$W2" >/dev/null 2>&1 || true -$K apply -f - >/dev/null </dev/null 2>&1 -$K -n project-vega run vega-orphan --image=busybox:1.36 --restart=Never --overrides="{\"spec\":{\"nodeName\":\"$W2\"}}" -- sleep 86400 >/dev/null 2>&1 -$K -n project-vega wait --for=condition=Ready pod/vega-orphan --timeout=90s >/dev/null 2>&1 -# what the drain has to clear: every non-DaemonSet, non-mirror, non-kube-system pod on the node now -$K get pods -A -o json | python3 -c ' -import sys,json -n=sys.argv[1] -for p in json.load(sys.stdin)["items"]: - m=p["metadata"]; o=(m.get("ownerReferences") or [{}])[0].get("kind") - if p["spec"].get("nodeName")==n and m["namespace"]!="kube-system" and o not in ("DaemonSet","Node"): - print(m["uid"])' "$W2" > "$WS/.q208-uids" -""", - checks=[ - C("every pod that was on the node when it was seeded has been evicted", 4, r""" -[ -s "$WS/.q208-uids" ] || exit 1 -now=$($K get pods -A -o jsonpath='{range .items[*]}{.metadata.uid}{" "}{.spec.nodeName}{"\n"}{end}' 2>/dev/null) -while read -r u; do echo "$now" | grep -q "^$u $W2\$" && exit 1; done < "$WS/.q208-uids" -! $K -n project-vega get pod vega-orphan >/dev/null 2>&1"""), - C("drain.sh records a drain with --ignore-daemonsets and --delete-emptydir-data", 2, r""" -f="$WS/q208/drain.sh"; grep -q 'drain' "$f" 2>/dev/null && grep -q -- '--ignore-daemonsets' "$f" && grep -q -- '--delete-emptydir-data' "$f" """), - C("vega-web still has 3 replicas Available", 2, r"""eq "$(j -n project-vega deploy vega-web '{.status.availableReplicas}')" 3"""), - C("node is schedulable again", 2, r"""[ "$(j node "$W2" '{.spec.unschedulable}')" != true ]"""), - ], - solution=""" -k get pods -A -o wide --field-selector spec.nodeName={W2} # what is about to be evicted or deleted -k drain {W2} --ignore-daemonsets --delete-emptydir-data --force -# --ignore-daemonsets DaemonSet pods can't be evicted, skip them -# --delete-emptydir-data required because vega-web mounts an emptyDir (its data is lost) -# --force required for the bare pod vega-orphan - which is DELETED, not moved -echo 'kubectl drain {W2} --ignore-daemonsets --delete-emptydir-data --force' > {WS}/q208/drain.sh -k get pods -A -o wide --field-selector spec.nodeName={W2} # only DaemonSet pods left -k -n project-vega rollout status deploy vega-web -k uncordon {W2} -""", -) - -# ------------------------------------------------------------------ 209 ---- -task( - id=209, domain="ARCH", title="Query the API from inside a Pod", - task=""" -Namespace `project-orbit` has ServiceAccount `cm-reader`, which may read ConfigMaps cluster-wide. - -1. Create Pod `api-probe` in `project-orbit`, image `nginx:1-alpine`, using that ServiceAccount. -2. From **inside** the Pod, query the Kubernetes API for all ConfigMaps in Namespace `project-orbit` - using `curl` and the Pod's ServiceAccount token, verifying the server certificate with the - mounted CA (do not use `-k`). -3. Save the JSON response to `{WS}/q209/configmaps.json`. -4. Write the API server URL you used, as seen from inside the Pod, to `{WS}/q209/endpoint.txt`. -""", - setup=NS + r""" -mkns project-orbit -rm -rf "$WS/q209"; mkdir -p "$WS/q209" -$K -n project-orbit delete pod api-probe --ignore-not-found --wait=false >/dev/null -$K -n project-orbit create sa cm-reader --dry-run=client -o yaml | $K apply -f - >/dev/null -$K delete clusterrole cm-reader --ignore-not-found >/dev/null; $K delete clusterrolebinding cm-reader --ignore-not-found >/dev/null -$K create clusterrole cm-reader --verb=get,list --resource=configmaps >/dev/null -$K create clusterrolebinding cm-reader --clusterrole=cm-reader --serviceaccount=project-orbit:cm-reader >/dev/null -$K -n project-orbit create cm orbit-settings --from-literal=region=us-east --dry-run=client -o yaml | $K apply -f - >/dev/null -""", - checks=[ - C("Pod api-probe Running with ServiceAccount cm-reader", 2, r""" -eq "$(j -n project-orbit pod api-probe '{.status.phase}')" Running && eq "$(j -n project-orbit pod api-probe '{.spec.serviceAccountName}')" cm-reader"""), - C("configmaps.json is a real ConfigMapList containing orbit-settings", 4, r""" -python3 -c ' -import json,sys -d=json.load(open("'"$WS"'/q209/configmaps.json")) -sys.exit(0 if d.get("kind")=="ConfigMapList" and any(i["metadata"]["name"]=="orbit-settings" for i in d.get("items",[])) else 1)' 2>/dev/null"""), - C("the response was authorised (not a 403 Status object)", 2, r"""! grep -q '"kind": *"Status"' "$WS/q209/configmaps.json" 2>/dev/null"""), - C("endpoint.txt names the in-cluster API service", 1, r"""grep -Eq 'kubernetes(\.default)?' "$WS/q209/endpoint.txt" 2>/dev/null"""), - ], - solution=""" -k -n project-orbit run api-probe --image=nginx:1-alpine $do > p.yaml -# add: spec.serviceAccountName: cm-reader -k apply -f p.yaml -k -n project-orbit exec -it api-probe -- sh - TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt - curl --cacert $CACERT -H "Authorization: Bearer $TOKEN" \\ - https://kubernetes.default/api/v1/namespaces/project-orbit/configmaps > /tmp/cm.json - exit -k -n project-orbit exec api-probe -- cat /tmp/cm.json > {WS}/q209/configmaps.json -echo https://kubernetes.default > {WS}/q209/endpoint.txt -# Without the token you are system:anonymous and get 403; with -k you skip verification, -# which the task forbids - the CA is mounted next to the token. -""", -) - -# ------------------------------------------------------------------ 210 ---- -task( - id=210, domain="ARCH", title="ServiceAccount limited to two resources", - task=""" -In Namespace `project-atlas` create ServiceAccount `pipeline`, plus a Role and a RoleBinding both -named `pipeline`, so that the ServiceAccount may **only**: - -- `create` and `update` **secrets** and **configmaps** in that Namespace. - -It must not be able to read them back, delete them, or touch anything in another Namespace. -""", - setup=NS + r""" -mkns project-atlas -$K -n project-atlas delete sa pipeline --ignore-not-found >/dev/null -$K -n project-atlas delete role pipeline --ignore-not-found >/dev/null -$K -n project-atlas delete rolebinding pipeline --ignore-not-found >/dev/null -""", - checks=[ - C("ServiceAccount, Role and RoleBinding named pipeline exist", 2, r"""$K -n project-atlas get sa pipeline >/dev/null 2>&1 && $K -n project-atlas get role pipeline >/dev/null 2>&1 && $K -n project-atlas get rolebinding pipeline >/dev/null 2>&1"""), - C("can create and update secrets and configmaps in project-atlas", 3, r""" -for r in secrets configmaps; do for v in create update; do - eq "$($K auth can-i $v $r -n project-atlas --as=system:serviceaccount:project-atlas:pipeline 2>/dev/null)" yes || exit 1 -done; done"""), - C("cannot get/list/delete them, and has nothing elsewhere", 3, r""" -for t in "get secrets -n project-atlas" "list configmaps -n project-atlas" "delete secrets -n project-atlas" "create secrets -n default"; do - eq "$($K auth can-i $t --as=system:serviceaccount:project-atlas:pipeline 2>/dev/null || true)" no || exit 1 -done"""), - ], - solution=""" -k -n project-atlas create sa pipeline -k -n project-atlas create role pipeline --verb=create,update --resource=secrets,configmaps -k -n project-atlas create rolebinding pipeline --role=pipeline --serviceaccount=project-atlas:pipeline -k -n project-atlas auth can-i create secret --as=system:serviceaccount:project-atlas:pipeline # yes -k -n project-atlas auth can-i get secret --as=system:serviceaccount:project-atlas:pipeline # no -""", -) - -# ------------------------------------------------------------------ 211 ---- -task( - id=211, domain="WORK", title="DaemonSet on every node", breaks=["w1-taint", "w1-block"], - task=""" -In Namespace `project-orion` create a DaemonSet `node-collector`: - -- image `httpd:2-alpine` -- labels on the DaemonSet **and** its Pods: `id=node-collector` and `tier=infra` -- each Pod requests 15 millicores of CPU and 15 mebibytes of memory -- a Pod must run on **every** node in the cluster, control-plane included - -Every desired Pod must be Ready. -""", - setup=NS + r"""mkns project-orion; $K -n project-orion delete ds node-collector --ignore-not-found >/dev/null""", - checks=[ - C("DaemonSet exists with both labels on the object and the pod template", 2, r""" -d="$K -n project-orion get ds node-collector" -eq "$($d -o jsonpath='{.metadata.labels.id}' 2>/dev/null)" node-collector && eq "$($d -o jsonpath='{.metadata.labels.tier}')" infra \ - && eq "$($d -o jsonpath='{.spec.template.metadata.labels.id}')" node-collector && eq "$($d -o jsonpath='{.spec.template.metadata.labels.tier}')" infra"""), - C("requests are 15m CPU / 15Mi memory", 2, r""" -eq "$(j -n project-orion ds node-collector '{.spec.template.spec.containers[0].resources.requests.cpu}')" 15m \ - && eq "$(j -n project-orion ds node-collector '{.spec.template.spec.containers[0].resources.requests.memory}')" 15Mi"""), - C("one Ready pod on every node including the control-plane", 4, r""" -n=$($K get nodes --no-headers 2>/dev/null | wc -l) -eq "$(j -n project-orion ds node-collector '{.status.desiredNumberScheduled}')" "$n" && eq "$(j -n project-orion ds node-collector '{.status.numberReady}')" "$n" \ - && j -n project-orion pod -l id=node-collector '{.items[*].spec.nodeName}' | tr ' ' '\n' | grep -q control-plane"""), - ], - solution=""" -k -n project-orion create deploy node-collector --image=httpd:2-alpine $do > ds.yaml -# change kind Deployment -> DaemonSet, drop replicas/strategy/status, then add: -# metadata.labels: {id: node-collector, tier: infra} -# spec.selector.matchLabels + spec.template.metadata.labels: same two labels -# container resources.requests: {cpu: 15m, memory: 15Mi} -# spec.template.spec.tolerations: -# - {key: node-role.kubernetes.io/control-plane, operator: Exists, effect: NoSchedule} -k apply -f ds.yaml -k -n project-orion get ds node-collector -o wide -""", -) - -# ------------------------------------------------------------------ 212 ---- -task( - id=212, domain="WORK", title="One Pod per node, on purpose", breaks=["w1-taint", "w1-block"], - task=""" -In Namespace `project-orion` create Deployment `spread-check`: - -- 4 replicas, Pods labelled `id=spread-check` -- container `main` with image `nginx:1-alpine`, and a second container `pause` with image - `registry.k8s.io/pause:3.10` -- at most **one** of its Pods on any worker node (use `topologyKey: kubernetes.io/hostname`) - -Because there are only three worker nodes, one Pod will stay `Pending` — that is expected. -Write the name of the Pending Pod to `{WS}/q212/pending.txt`. -""", - setup=NS + r"""mkns project-orion; rm -rf "$WS/q212"; mkdir -p "$WS/q212"; $K -n project-orion delete deploy spread-check --ignore-not-found >/dev/null""", - checks=[ - C("Deployment has 4 replicas and both containers", 2, r""" -d="$K -n project-orion get deploy spread-check" -eq "$($d -o jsonpath='{.spec.replicas}' 2>/dev/null)" 4 \ - && $d -o jsonpath='{.spec.template.spec.containers[*].name}' | grep -qw main \ - && $d -o jsonpath='{.spec.template.spec.containers[*].image}' | grep -q 'pause:3.10'"""), - C("anti-affinity or spread constraint on kubernetes.io/hostname", 3, r""" -t=$(j -n project-orion deploy spread-check '{.spec.template.spec}') -echo "$t" | grep -q '"topologyKey":"kubernetes.io/hostname"' && { echo "$t" | grep -q 'podAntiAffinity' || echo "$t" | grep -q 'topologySpreadConstraints'; }"""), - C("exactly 3 Pods Running, one per node, 1 Pending", 3, r""" -r=$($K -n project-orion get pod -l id=spread-check --no-headers 2>/dev/null | grep -c Running) -p=$($K -n project-orion get pod -l id=spread-check --no-headers 2>/dev/null | grep -c Pending) -eq "$r" 3 && eq "$p" 1 && [ "$(j -n project-orion pod -l id=spread-check '{.items[*].spec.nodeName}' | tr ' ' '\n' | grep . | sort -u | wc -l)" = 3 ]"""), - C("pending.txt names the Pending pod", 2, r""" -want=$($K -n project-orion get pod -l id=spread-check --no-headers 2>/dev/null | awk '$3=="Pending"{print $1}') -[ -n "$want" ] && grep -qF "$want" "$WS/q212/pending.txt" 2>/dev/null"""), - ], - solution=""" -k -n project-orion create deploy spread-check --image=nginx:1-alpine --replicas=4 $do > s.yaml -# set the pod label id=spread-check (selector + template), rename the container to main, -# add the second container, and add either: -# affinity: -# podAntiAffinity: -# requiredDuringSchedulingIgnoredDuringExecution: -# - labelSelector: {matchLabels: {id: spread-check}} -# topologyKey: kubernetes.io/hostname -# or: -# topologySpreadConstraints: -# - {maxSkew: 1, topologyKey: kubernetes.io/hostname, whenUnsatisfiable: DoNotSchedule, -# labelSelector: {matchLabels: {id: spread-check}}} -k apply -f s.yaml -k -n project-orion get pod -l id=spread-check -o wide -k -n project-orion get pod -l id=spread-check --field-selector status.phase=Pending -o name > {WS}/q212/pending.txt -k -n project-orion describe pod # "didn't match pod anti-affinity rules" -""", -) - -# ------------------------------------------------------------------ 213 ---- -task( - id=213, domain="NET", title="Convert an Ingress to Gateway API, then split on a header", - task=""" -Namespace `project-r700` has Services `web-classic` and `web-next` (both port 80) and an existing -Gateway named `edge`. The legacy Ingress is at `{WS}/q213/ingress.yaml`. - -Create an HTTPRoute named `router` in that Namespace, attached to the Gateway `edge`, which: - -1. reproduces the routes of the old Ingress (host and paths included), -2. adds a path `/beta` that goes to `web-next` **only when** the header `X-Channel` is exactly - `beta`, and to `web-classic` in every other case. - -Path matching is `PathPrefix` throughout. Order matters. -""", - setup=NS + r""" -mkns project-r700 -rm -rf "$WS/q213"; mkdir -p "$WS/q213" -for s in web-classic web-next; do - $K -n project-r700 create deploy $s --image=nginx:1.27 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null - $K -n project-r700 expose deploy $s --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null -done -cat > "$WS/q213/ingress.yaml" <<'EOF' -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: {name: router, namespace: project-r700} -spec: - ingressClassName: nginx - rules: - - host: r700.gateway - http: - paths: - - {path: /classic, pathType: Prefix, backend: {service: {name: web-classic, port: {number: 80}}}} - - {path: /next, pathType: Prefix, backend: {service: {name: web-next, port: {number: 80}}}} -EOF -$K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 || { - curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml -o /tmp/gwapi.yaml && $K apply -f /tmp/gwapi.yaml >/dev/null 2>&1; } || echo "warn: could not install Gateway API CRDs" -$K -n project-r700 delete httproute router --ignore-not-found >/dev/null 2>&1 -$K apply -f - >/dev/null 2>&1 <<'EOF' -apiVersion: gateway.networking.k8s.io/v1 -kind: GatewayClass -metadata: {name: edge-class} -spec: {controllerName: example.com/edge} ---- -apiVersion: gateway.networking.k8s.io/v1 -kind: Gateway -metadata: {name: edge, namespace: project-r700} -spec: - gatewayClassName: edge-class - listeners: [{name: http, protocol: HTTP, port: 80, hostname: r700.gateway}] -EOF -""", - checks=[ - C("HTTPRoute router attached to Gateway edge, hostname r700.gateway", 2, r""" -h="$K -n project-r700 get httproute router" -eq "$($h -o jsonpath='{.spec.parentRefs[0].name}' 2>/dev/null)" edge && $h -o jsonpath='{.spec.hostnames[*]}' | grep -q r700.gateway"""), - C("/classic -> web-classic:80 and /next -> web-next:80", 3, r""" -$K -n project-r700 get httproute router -o json 2>/dev/null | python3 -c ' -import sys,json -rs=json.load(sys.stdin)["spec"]["rules"] -def be(path): - for r in rs: - for m in r.get("matches",[]): - if m.get("path",{}).get("value")==path and m.get("path",{}).get("type")=="PathPrefix" and not m.get("headers"): - b=r["backendRefs"][0]; return b["name"], b.get("port") - return None -sys.exit(0 if be("/classic")==("web-classic",80) and be("/next")==("web-next",80) else 1)'"""), - C("/beta with X-Channel=beta goes to web-next (path AND header in one match)", 3, r""" -$K -n project-r700 get httproute router -o json 2>/dev/null | python3 -c ' -import sys,json -rs=json.load(sys.stdin)["spec"]["rules"] -ok=False -for r in rs: - for m in r.get("matches",[]): - hs=m.get("headers") or [] - if m.get("path",{}).get("value")=="/beta" and any(h.get("name","").lower()=="x-channel" and h.get("value")=="beta" for h in hs): - if len(r["backendRefs"])==1 and r["backendRefs"][0]["name"]=="web-next": ok=True -sys.exit(0 if ok else 1)'"""), - C("/beta catch-all goes to web-classic and comes after the header rule", 3, r""" -$K -n project-r700 get httproute router -o json 2>/dev/null | python3 -c ' -import sys,json -rs=json.load(sys.stdin)["spec"]["rules"] -hi=ci=None -for i,r in enumerate(rs): - for m in r.get("matches",[]): - if m.get("path",{}).get("value")=="/beta": - if m.get("headers"): - if hi is None: hi=i - else: - if ci is None and r["backendRefs"][0]["name"]=="web-classic": ci=i -sys.exit(0 if hi is not None and ci is not None and hi/dev/null && grep -qi "$mon" "$f" && grep -q "$day" "$f" """), - C("kubeadm-line.txt is the apiserver-etcd-client row from kubeadm", 3, r"""grep -q 'apiserver-etcd-client' "$WS/q214/kubeadm-line.txt" 2>/dev/null && grep -Eq '[0-9]{4}' "$WS/q214/kubeadm-line.txt" """), - C("renew.sh renews only that certificate", 2, r"""grep -Eq 'kubeadm +certs +renew +apiserver-etcd-client' "$WS/q214/renew.sh" 2>/dev/null"""), - ], - solution=""" -{SHELL_CP} -openssl x509 -in /etc/kubernetes/pki/apiserver-etcd-client.crt -noout -enddate -# notAfter=... -> copy into {WS}/q214/etcd-client-expiry.txt -kubeadm certs check-expiration | grep apiserver-etcd-client -# -> copy that line into {WS}/q214/kubeadm-line.txt -echo 'kubeadm certs renew apiserver-etcd-client' > {WS}/q214/renew.sh -# openssl -noout -text | grep -A2 Validity shows both notBefore and notAfter if you prefer. -""", -) - -# ------------------------------------------------------------------ 215 ---- -task( - id=215, domain="NET", title="Egress policy with paired destinations", - task=""" -Namespace `project-snake2` runs Pods labelled `app=api`, `app=cache`, `app=metrics` and `app=audit`. - -After a breach, the `api` Pods must be restricted. Create a NetworkPolicy `np-api` in that -Namespace so that Pods with `app=api` may open connections **only**: - -- to `app=cache` Pods on TCP port 6379, -- to `app=metrics` Pods on TCP port 9090. - -Any other egress — including to `cache` on 9090 or to `metrics` on 6379 — must be denied. -Ingress must not be restricted by this policy. -""", - setup=NS + r""" -mkns project-snake2 -for a in api cache metrics audit; do - $K -n project-snake2 create deploy $a --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null - $K -n project-snake2 label deploy $a app=$a --overwrite >/dev/null - $K -n project-snake2 patch deploy $a -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"app\":\"$a\"}}}}}" >/dev/null -done -$K -n project-snake2 delete netpol np-api --ignore-not-found >/dev/null -""", - checks=[ - C("policy selects app=api and is Egress-only", 2, r""" -n="$K -n project-snake2 get netpol np-api" -eq "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" api && eq "$($n -o jsonpath='{.spec.policyTypes}')" '["Egress"]'"""), - C("two separate rules pair each destination with its own port", 5, r""" -$K -n project-snake2 get netpol np-api -o json 2>/dev/null | python3 -c ' -import sys,json -e=json.load(sys.stdin)["spec"].get("egress",[]) -def pair(app,port): - for r in e: - tos=r.get("to",[]); ports=r.get("ports",[]) - if len(tos)==1 and tos[0].get("podSelector",{}).get("matchLabels",{}).get("app")==app \ - and len(ports)==1 and str(ports[0].get("port"))==str(port) and ports[0].get("protocol","TCP")=="TCP": - return True - return False -sys.exit(0 if len(e)==2 and pair("cache",6379) and pair("metrics",9090) else 1)'"""), - C("no rule allows a destination on the wrong port (single combined rule fails here)", 3, r""" -$K -n project-snake2 get netpol np-api -o json 2>/dev/null | python3 -c ' -import sys,json -e=json.load(sys.stdin)["spec"].get("egress",[]) -for r in e: - apps={t.get("podSelector",{}).get("matchLabels",{}).get("app") for t in r.get("to",[])} - ports={str(p.get("port")) for p in r.get("ports",[])} - if len(apps)>1 or len(ports)>1: sys.exit(1) # combined rule = cross product = too permissive -sys.exit(0)'"""), - ], - solution=""" -cat < cache:9090. That is the trap. -""", -) - -# ------------------------------------------------------------------ 216 ---- -task( - id=216, domain="NET", title="CoreDNS: back up, extend, verify", breaks="coredns", - task=""" -1. Back up the current CoreDNS configuration to `{WS}/q216/coredns-backup.yaml` in a form you - could restore from directly with `kubectl apply -f`. -2. Change CoreDNS so that `SERVICE.NAMESPACE.svc.internal.zone` resolves to the same address as - `SERVICE.NAMESPACE.svc.cluster.local`, **in addition** to the existing name. -3. Roll CoreDNS so the change is live. -4. Prove it: write the output of `nslookup kubernetes.default.svc.internal.zone` from a Pod to - `{WS}/q216/lookup.txt`. -""", - setup=r""" -rm -rf "$WS/q216"; mkdir -p "$WS/q216" -cur=$(j -n kube-system cm coredns '{.data.Corefile}') -echo "$cur" | grep -q internal.zone && { echo "$cur" | sed 's/ internal.zone//' > /tmp/cf216; $K -n kube-system create cm coredns --from-file=Corefile=/tmp/cf216 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n kube-system rollout restart deploy coredns >/dev/null; } -$K -n default delete pod dns-client --ignore-not-found --wait=false >/dev/null -""", - checks=[ - C("backup file is a restorable ConfigMap manifest with the original Corefile", 3, r""" -f="$WS/q216/coredns-backup.yaml"; grep -q 'kind: ConfigMap' "$f" 2>/dev/null && grep -q 'name: coredns' "$f" && grep -q 'Corefile' "$f" && grep -q 'kubernetes cluster.local' "$f" """), - C("Corefile serves internal.zone alongside cluster.local", 3, r"""j -n kube-system cm coredns '{.data.Corefile}' | grep -E '^ *kubernetes ' | grep -q 'internal.zone' """), - C("CoreDNS rolled out and available", 2, r"""for i in $(seq 1 12); do [ "$(j -n kube-system deploy coredns '{.status.availableReplicas}')" -ge 1 ] 2>/dev/null && exit 0; sleep 5; done; exit 1"""), - C("lookup.txt resolves the new name to the kubernetes Service IP", 3, r""" -ip=$(j -n default svc kubernetes '{.spec.clusterIP}'); grep -q "$ip" "$WS/q216/lookup.txt" 2>/dev/null && grep -q 'internal.zone' "$WS/q216/lookup.txt" """), - ], - solution=""" -k -n kube-system get cm coredns -o yaml > {WS}/q216/coredns-backup.yaml -k -n kube-system edit cm coredns -# kubernetes cluster.local in-addr.arpa ip6.arpa { -# becomes -# kubernetes cluster.local internal.zone in-addr.arpa ip6.arpa { -k -n kube-system rollout restart deploy coredns -k -n kube-system rollout status deploy coredns -k run dns-client --image=busybox:1.36 --restart=Never -- sleep 3600 -k exec dns-client -- nslookup kubernetes.default.svc.internal.zone | tee {WS}/q216/lookup.txt -# Restore if needed: k delete -f {WS}/q216/coredns-backup.yaml && k apply -f {WS}/q216/coredns-backup.yaml -""", -) - -# ------------------------------------------------------------------ 217 ---- -task( - id=217, domain="TSHOOT", title="Inspect a container with crictl", - task=""" -1. In Namespace `project-tiger2` create Pod `tiger-probe`, image `httpd:2-alpine`, with labels - `pod=probe` and `probe=pod`. -2. Find which node it runs on, connect to that node, and locate its container with `crictl`. -3. Write the container ID and its `runtimeType` (from `crictl inspect`), separated by a space, - to `{WS}/q217/container.txt`. -4. Write the container's logs to `{WS}/q217/container.log`. -""", - setup=NS + r""" -mkns project-tiger2 -rm -rf "$WS/q217"; mkdir -p "$WS/q217" -$K -n project-tiger2 delete pod tiger-probe --ignore-not-found --wait=false >/dev/null -""", - checks=[ - C("Pod tiger-probe Running with both labels", 2, r""" -eq "$(j -n project-tiger2 pod tiger-probe '{.status.phase}')" Running && eq "$(j -n project-tiger2 pod tiger-probe '{.metadata.labels.pod}')" probe && eq "$(j -n project-tiger2 pod tiger-probe '{.metadata.labels.probe}')" pod"""), - C("container.txt holds a real container ID for that Pod", 4, r""" -id=$(awk '{print $1}' "$WS/q217/container.txt" 2>/dev/null); [ -n "$id" ] || exit 1 -node=$(j -n project-tiger2 pod tiger-probe '{.spec.nodeName}') -cid=$(j -n project-tiger2 pod tiger-probe '{.status.containerStatuses[0].containerID}' | sed 's|.*//||') -case "$cid" in "$id"*) exit 0;; esac; exit 1"""), - C("container.txt records the runtimeType", 2, r"""grep -q 'io.containerd.runc' "$WS/q217/container.txt" 2>/dev/null"""), - C("container.log holds the httpd startup output", 2, r"""grep -qi 'httpd' "$WS/q217/container.log" 2>/dev/null"""), - ], - solution=""" -k -n project-tiger2 run tiger-probe --image=httpd:2-alpine --labels "pod=probe,probe=pod" -k -n project-tiger2 get pod tiger-probe -o wide # note the NODE -{SHELL_W1} # ...whichever node it landed on -crictl ps | grep tiger-probe # -> container id -crictl inspect | grep runtimeType # -> io.containerd.runc.v2 -crictl logs -# write " io.containerd.runc.v2" into {WS}/q217/container.txt and the logs into container.log -# crictl ps -a also shows exited containers; crictl pods lists sandboxes. -""", -) - -QUESTIONS = Q diff --git a/tracks/cka/bank_sim_b.py b/tracks/cka/bank_sim_b.py deleted file mode 100644 index fc90112..0000000 --- a/tracks/cka/bank_sim_b.py +++ /dev/null @@ -1,1028 +0,0 @@ -"""Simulator Set B - 17 tasks, fixed set, killer.sh-grade difficulty. - -Original scenarios. Weighted toward node-level and control-plane work: etcd flags, kube-proxy -rules, ServiceCIDR, bound tokens, PDB-blocked drains, PV reclaim, upgrade planning, CSR signing. - -Run with: ./exam sim b -""" -from tracks.cka.common import C, NS - -Q = [] - - -def task(**kw): - kw.setdefault("difficulty", "hard") - kw["set"] = "b" - Q.append(kw) - - -# ------------------------------------------------------------------ 301 ---- -task( - id=301, domain="ARCH", title="etcd configuration facts", - task=""" -The security team wants the following about the etcd running on the control-plane node. -Write `{WS}/q301/etcd-info.txt` with exactly these five lines (values after the colon): - -``` -data-dir: -server-key: -trusted-ca: -client-cert-auth: -server-cert-expiry: -``` - -Take every value from how etcd is actually started, not from memory. -""", - setup=r"""rm -rf "$WS/q301"; mkdir -p "$WS/q301" """, - checks=[ - C("data-dir and server-key are correct", 3, r""" -f="$WS/q301/etcd-info.txt"; [ -f "$f" ] || exit 1 -cmd=$(j -n kube-system pod -l component=etcd '{.items[0].spec.containers[0].command}') -dd=$(echo "$cmd" | tr ',' '\n' | grep -o 'data-dir=[^"]*' | cut -d= -f2) -sk=$(echo "$cmd" | tr ',' '\n' | grep -o '\-\-key-file=[^"]*' | cut -d= -f2) -grep -q "data-dir: *$dd" "$f" && grep -q "server-key: *$sk" "$f" """), - C("trusted-ca and client-cert-auth are correct", 3, r""" -f="$WS/q301/etcd-info.txt" -cmd=$(j -n kube-system pod -l component=etcd '{.items[0].spec.containers[0].command}') -ca=$(echo "$cmd" | tr ',' '\n' | grep -o '\-\-trusted-ca-file=[^"]*' | cut -d= -f2) -grep -q "trusted-ca: *$ca" "$f" && grep -Eqi 'client-cert-auth: *(yes|true)' "$f" """), - C("server-cert-expiry matches the certificate on disk", 3, r""" -exp=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/etcd/server.crt -noout -enddate' | sed 's/notAfter=//') -yr=$(echo "$exp" | awk '{print $4}'); mon=$(echo "$exp" | awk '{print $1}'); day=$(echo "$exp" | awk '{print $2}') -f="$WS/q301/etcd-info.txt"; grep -q "$yr" "$f" 2>/dev/null && grep -qi "$mon" "$f" && grep -q "$day" "$f" """), - ], - solution=""" -{SHELL_CP} -cat /etc/kubernetes/manifests/etcd.yaml # every flag etcd runs with -# --data-dir=/var/lib/etcd -# --key-file=/etc/kubernetes/pki/etcd/server.key -# --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt -# --client-cert-auth=true -openssl x509 -in /etc/kubernetes/pki/etcd/server.crt -noout -enddate -# etcd runs as a static pod, so the manifest in /etc/kubernetes/manifests is the source of truth. -# k -n kube-system get pod -l component=etcd -o yaml would show the same command. -""", -) - -# ------------------------------------------------------------------ 302 ---- -task( - id=302, domain="NET", title="Service rules on the node", breaks="proxy-rules", - task=""" -Confirm that kube-proxy is programming this cluster. - -1. In Namespace `project-proxy` create Pod `proxy-pod` (image `nginx:1-alpine`) and expose it - internally as Service `proxy-svc` on port **4000** targeting container port 80. -2. From the node where the Pod runs, capture the proxy rules that mention `proxy-svc` into - `{WS}/q302/rules.txt` (the node uses iptables; `iptables-save` is available as root). -3. Write the Service's ClusterIP to `{WS}/q302/clusterip.txt`. -4. Write the proxy mode kube-proxy is running in to `{WS}/q302/mode.txt` - (an empty `mode` in its ConfigMap means `iptables`). -""", - setup=NS + r""" -mkns project-proxy -rm -rf "$WS/q302"; mkdir -p "$WS/q302" -$K -n project-proxy delete pod proxy-pod --ignore-not-found --wait=false >/dev/null -$K -n project-proxy delete svc proxy-svc --ignore-not-found >/dev/null -""", - checks=[ - C("Pod and Service exist, 4000 -> 80, with endpoints", 3, r""" -eq "$(j -n project-proxy pod proxy-pod '{.status.phase}')" Running \ - && eq "$(j -n project-proxy svc proxy-svc '{.spec.ports[0].port}')" 4000 && eq "$(j -n project-proxy svc proxy-svc '{.spec.ports[0].targetPort}')" 80 \ - && [ -n "$(j -n project-proxy endpointslices -l kubernetes.io/service-name=proxy-svc '{.items[*].endpoints[*].addresses[*]}')" ]"""), - C("clusterip.txt matches the Service", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q302/clusterip.txt" 2>/dev/null)" "$(j -n project-proxy svc proxy-svc '{.spec.clusterIP}')" """), - C("rules.txt contains real proxy rules naming the Service and its ClusterIP", 4, r""" -f="$WS/q302/rules.txt"; grep -q 'proxy-svc' "$f" 2>/dev/null && grep -q "$(j -n project-proxy svc proxy-svc '{.spec.clusterIP}')" "$f" && grep -Eq 'KUBE-(SVC|SEP|SERVICES)' "$f" """), - C("mode.txt says iptables", 2, r"""grep -qi 'iptables' "$WS/q302/mode.txt" 2>/dev/null"""), - ], - solution=""" -k -n project-proxy run proxy-pod --image=nginx:1-alpine -k -n project-proxy expose pod proxy-pod --name proxy-svc --port 4000 --target-port 80 -k -n project-proxy get svc proxy-svc -o jsonpath='{.spec.clusterIP}' > {WS}/q302/clusterip.txt -k -n project-proxy get pod proxy-pod -o wide # note the node -{SHELL_W1} # the node it landed on -iptables-save | grep proxy-svc # -> copy into {WS}/q302/rules.txt -exit -k -n kube-system get cm kube-proxy -o yaml | grep -E '^ *mode:' # empty -> iptables -echo iptables > {WS}/q302/mode.txt -# Deleting the Service makes the rules disappear within a second - kube-proxy watches the API -# and rewrites the rules on every Service/EndpointSlice change. -""", -) - -# ------------------------------------------------------------------ 303 ---- -task( - id=303, domain="NET", title="A second Service CIDR", - task=""" -The cluster is running out of Service IPs. - -1. Write the CIDR the cluster currently allocates Service IPs from to `{WS}/q303/current-cidr.txt`. -2. Add a **second** range `10.180.0.0/16` to the cluster by creating a `ServiceCIDR` object named - `extra-range` (do not remove the existing one). -3. In Namespace `default` create Deployment `cidr-demo` (image `nginx:1-alpine`) and a ClusterIP - Service `cidr-demo` on port 80 whose ClusterIP is taken **from the new range** - (request the address explicitly if you need to). -4. Write that Service's ClusterIP to `{WS}/q303/new-ip.txt`. -""", - setup=r""" -rm -rf "$WS/q303"; mkdir -p "$WS/q303" -$K delete svc cidr-demo --ignore-not-found >/dev/null 2>&1 -$K delete deploy cidr-demo --ignore-not-found >/dev/null 2>&1 -$K delete servicecidr extra-range --ignore-not-found >/dev/null 2>&1 -$K get servicecidr >/dev/null 2>&1 || echo "warn: this cluster has no ServiceCIDR API (needs MultiCIDRServiceAllocator)" -""", - checks=[ - C("current-cidr.txt matches the apiserver's service range", 2, r""" -sc=$(j -n kube-system pod -l component=kube-apiserver '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'service-cluster-ip-range=[^"]*' | cut -d= -f2) -grep -qF "$sc" "$WS/q303/current-cidr.txt" 2>/dev/null"""), - C("ServiceCIDR extra-range covers 10.180.0.0/16 and is Ready", 4, r""" -j servicecidr extra-range '{.spec.cidrs[*]}' | grep -q '10.180.0.0/16' || exit 1 -for i in $(seq 1 10); do [ "$(j servicecidr extra-range '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && exit 0; sleep 2; done; exit 1"""), - C("Service cidr-demo has an IP from the new range and ready endpoints", 4, r""" -ip=$(j -n default svc cidr-demo '{.spec.clusterIP}'); case "$ip" in 10.180.*) ;; *) exit 1;; esac -[ -n "$(j -n default endpointslices -l kubernetes.io/service-name=cidr-demo '{.items[*].endpoints[?(@.conditions.ready==true)].addresses[*]}')" ]"""), - C("new-ip.txt records that ClusterIP", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q303/new-ip.txt" 2>/dev/null)" "$(j -n default svc cidr-demo '{.spec.clusterIP}')" """), - ], - solution=""" -k -n kube-system get pod -l component=kube-apiserver -o yaml | grep service-cluster-ip-range -# -> e.g. 10.96.0.0/16 ; write it into {WS}/q303/current-cidr.txt -k get servicecidr # the built-in "kubernetes" ServiceCIDR -cat < {WS}/q303/new-ip.txt -# New ranges are additive: existing Services keep their IPs, and allocation from a new range -# happens once the ServiceCIDR is Ready. Requesting --cluster-ip makes it deterministic. -""", -) - -# ------------------------------------------------------------------ 304 ---- -task( - id=304, domain="ARCH", title="Bound tokens versus legacy tokens", - task=""" -In Namespace `project-token` there is ServiceAccount `reporter`. - -1. Issue a **bound token** for it that is valid for exactly 2 hours and save it to - `{WS}/q304/token.jwt`. -2. Decode the token's payload and write its `exp` and `iat` claims, as - `exp=` and `iat=` on two lines, to `{WS}/q304/claims.txt`. -3. Also create a **long-lived** token for the same ServiceAccount, as a Secret named - `reporter-legacy` of the correct type, and confirm the controller populated it. -""", - setup=NS + r""" -mkns project-token -rm -rf "$WS/q304"; mkdir -p "$WS/q304" -$K -n project-token create sa reporter --dry-run=client -o yaml | $K apply -f - >/dev/null -$K -n project-token delete secret reporter-legacy --ignore-not-found >/dev/null -""", - checks=[ - C("token.jwt is a JWT issued for the reporter ServiceAccount", 3, r""" -f="$WS/q304/token.jwt"; [ -s "$f" ] || exit 1 -python3 - "$f" <<'PY' -import base64,json,sys -t=open(sys.argv[1]).read().strip().split(".") -if len(t)!=3: sys.exit(1) -p=json.loads(base64.urlsafe_b64decode(t[1]+"=="*3)) -sub=p.get("sub",""); ko=p.get("kubernetes.io",{}).get("serviceaccount",{}).get("name","") -sys.exit(0 if "reporter" in sub or ko=="reporter" else 1) -PY"""), - C("the token's lifetime is 2 hours", 3, r""" -python3 - "$WS/q304/token.jwt" <<'PY' -import base64,json,sys -p=json.loads(base64.urlsafe_b64decode(open(sys.argv[1]).read().strip().split(".")[1]+"=="*3)) -d=p["exp"]-p["iat"] -sys.exit(0 if 7000 <= d <= 7400 else 1) -PY"""), - C("claims.txt records exp and iat matching the token", 2, r""" -python3 - "$WS/q304/token.jwt" "$WS/q304/claims.txt" <<'PY' -import base64,json,sys,re -p=json.loads(base64.urlsafe_b64decode(open(sys.argv[1]).read().strip().split(".")[1]+"=="*3)) -try: t=open(sys.argv[2]).read() -except OSError: sys.exit(1) -ok=re.search(r"exp\s*=\s*%d"%p["exp"],t) and re.search(r"iat\s*=\s*%d"%p["iat"],t) -sys.exit(0 if ok else 1) -PY"""), - C("Secret reporter-legacy is a populated service-account-token for reporter", 3, r""" -eq "$(j -n project-token secret reporter-legacy '{.type}')" kubernetes.io/service-account-token \ - && eq "$(j -n project-token secret reporter-legacy '{.metadata.annotations.kubernetes\.io/service-account\.name}')" reporter \ - && [ -n "$(j -n project-token secret reporter-legacy '{.data.token}')" ]"""), - ], - solution=""" -k -n project-token create token reporter --duration=2h > {WS}/q304/token.jwt -# decode the middle segment (JWTs are base64url, pad it out): -cut -d. -f2 {WS}/q304/token.jwt | base64 -d 2>/dev/null | python3 -m json.tool -# -> copy exp= and iat= into {WS}/q304/claims.txt -cat <`, and on a second line `scheduler=`. -""", - setup=NS + r""" -mkns project-orion -rm -rf "$WS/q305"; mkdir -p "$WS/q305" -$K -n project-orion delete pod direct-assign scheduled --ignore-not-found --wait=false >/dev/null -""", - # Who set spec.nodeName is recorded permanently in managedFields: a client that submits it - # (kubectl create/apply/run) owns f:nodeName, while a scheduler binding goes through the - # pods/binding subresource and leaves no managedFields entry for it (verified on 1.37). Events - # were the old signal, but plain `get events` has no SOURCE column and they expire after 1h. - checks=[ - C("direct-assign Running on {W2}", 3, r""" -eq "$(j -n project-orion pod direct-assign '{.status.phase}')" Running && eq "$(j -n project-orion pod direct-assign '{.spec.nodeName}')" "$W2" """), - C("direct-assign bypassed the scheduler (spec.nodeName came from your manifest)", 3, r""" -$K -n project-orion get pod direct-assign -o json --show-managed-fields 2>/dev/null | python3 -c ' -import sys,json -mf=json.load(sys.stdin)["metadata"].get("managedFields",[]) -sys.exit(0 if any("f:nodeName" in json.dumps(m.get("fieldsV1",{})) for m in mf if m.get("subresource","")!="status") else 1)'"""), - C("scheduled Running on {W2}, placed there by default-scheduler (no nodeName in its manifest)", 3, r""" -eq "$(j -n project-orion pod scheduled '{.status.phase}')" Running && eq "$(j -n project-orion pod scheduled '{.spec.nodeName}')" "$W2" \ - && eq "$(j -n project-orion pod scheduled '{.spec.schedulerName}')" default-scheduler || exit 1 -$K -n project-orion get pod scheduled -o json --show-managed-fields 2>/dev/null | python3 -c ' -import sys,json -mf=json.load(sys.stdin)["metadata"].get("managedFields",[]) -sys.exit(1 if any("f:nodeName" in json.dumps(m.get("fieldsV1",{})) for m in mf) else 0)'"""), - C("proof.txt names nodeName and default-scheduler", 2, r""" -grep -q 'field= *nodeName' "$WS/q305/proof.txt" 2>/dev/null && grep -q 'scheduler= *default-scheduler' "$WS/q305/proof.txt" """), - ], - solution=""" -cat < {WS}/q305/proof.txt -# `k get events` hides who reported an event; -o wide (or describe) shows the SOURCE column. -# Events expire after an hour, so for lasting proof look at who owns spec.nodeName: -# k -n project-orion get pod direct-assign -o yaml --show-managed-fields | grep -B2 -A2 nodeName -""", -) - -# ------------------------------------------------------------------ 306 ---- -task( - id=306, domain="STOR", title="Grow a volume in place", - task=""" -In Namespace `project-orbit`: - -1. Create StorageClass `expandable` using provisioner `rancher.io/local-path`, - `volumeBindingMode: WaitForFirstConsumer`, and allowing volume expansion. -2. Create PVC `growing` (1Gi, RWO) using that class and Pod `grower` (busybox:1.36, `sleep 86400`) - mounting it at `/data`, so the claim binds. -3. Then grow the claim to **2Gi** without deleting it, and write both sizes the claim reports - afterwards to `{WS}/q306/capacity.txt`, one per line: - `requested=<.spec.resources.requests.storage>` and `status=<.status.capacity.storage>`. - -(The local-path provisioner has no volume resizer, so on this cluster the status will not catch up -with the request — that gap is what step 3 asks you to observe. With a CSI driver that supports -expansion, the status would follow within seconds.) -""", - setup=NS + r""" -mkns project-orbit -rm -rf "$WS/q306"; mkdir -p "$WS/q306" -$K -n project-orbit delete pod grower --ignore-not-found --wait=false >/dev/null -$K -n project-orbit delete pvc growing --ignore-not-found >/dev/null -$K delete sc expandable --ignore-not-found >/dev/null -""", - checks=[ - C("StorageClass expandable: local-path, WaitForFirstConsumer, expansion allowed", 3, r""" -s="$K get sc expandable" -eq "$($s -o jsonpath='{.provisioner}' 2>/dev/null)" rancher.io/local-path && eq "$($s -o jsonpath='{.volumeBindingMode}')" WaitForFirstConsumer \ - && eq "$($s -o jsonpath='{.allowVolumeExpansion}')" true"""), - C("PVC growing Bound on that class with grower Running", 3, r""" -eq "$(j -n project-orbit pvc growing '{.spec.storageClassName}')" expandable && eq "$(j -n project-orbit pvc growing '{.status.phase}')" Bound \ - && eq "$(j -n project-orbit pod grower '{.status.phase}')" Running"""), - # local-path cannot resize (verified: ExternalExpanding, capacity never moves), so a bound PV - # that is still 1Gi under a claim requesting 2Gi is exactly what "grown in place" looks like - # here. A claim deleted and recreated at 2Gi would have been provisioned a 2Gi volume instead. - C("claim grown in place: requests 2Gi on the original 1Gi volume", 4, r""" -eq "$(j -n project-orbit pvc growing '{.spec.resources.requests.storage}')" 2Gi || exit 1 -pv=$(j -n project-orbit pvc growing '{.spec.volumeName}'); [ -n "$pv" ] || exit 1 -eq "$(j pv "$pv" '{.spec.capacity.storage}')" 1Gi"""), - C("capacity.txt records requested and status sizes as the cluster reports them", 1, r""" -f="$WS/q306/capacity.txt" -eq "$(sed -n 's/^ *requested *= *//p' "$f" 2>/dev/null | tr -d '[:space:]')" 2Gi \ - && eq "$(sed -n 's/^ *status *= *//p' "$f" | tr -d '[:space:]')" "$(j -n project-orbit pvc growing '{.status.capacity.storage}')" """), - ], - solution=""" -cat < {WS}/q306/capacity.txt -cat {WS}/q306/capacity.txt # requested=2Gi status=1Gi -# allowVolumeExpansion is the gate: without it the API rejects the patch outright, and shrinking is -# never allowed. Accepting the request is all the API does - a resizer (the CSI driver's -# external-resizer sidecar) then grows the volume and the kubelet the filesystem, and only then does -# .status.capacity change. local-path has no resizer, so here the status stays at 1Gi forever. -""", -) - -# ------------------------------------------------------------------ 307 ---- -task( - id=307, domain="TSHOOT", title="Node accepts nothing new", breaks=["w1-block", "w1-taint"], - task=""" -Node `{W1}` is `Ready` and its kubelet is healthy, but no new Pod will schedule onto it. - -Find every reason and remove them, so that a Pod with a `nodeSelector` pinning it to `{W1}` -starts successfully. Do not delete or recreate the node, and do not use `nodeName` to work around it. -""", - setup=r""" -$K cordon "$W1" >/dev/null 2>&1 || true -$K taint node "$W1" maintenance=inprogress:NoSchedule --overwrite >/dev/null 2>&1 || true -$K label node "$W1" kubernetes.io/os- >/dev/null 2>&1 || true -$K label node "$W1" kubernetes.io/os=linux --overwrite >/dev/null 2>&1 || true -""", - checks=[ - C("node is schedulable (uncordoned)", 3, r"""[ "$(j node "$W1" '{.spec.unschedulable}')" != true ]"""), - C("the maintenance taint is gone", 3, r"""! j node "$W1" '{.spec.taints}' | grep -q 'maintenance'"""), - C("a pinned Pod actually runs there", 4, r""" -$K -n default delete pod pin-probe --ignore-not-found --wait=true >/dev/null 2>&1 -cat </dev/null 2>&1 -apiVersion: v1 -kind: Pod -metadata: {name: pin-probe, namespace: default} -spec: - nodeSelector: {kubernetes.io/hostname: $W1} - containers: [{name: c, image: busybox:1.36, command: [sleep, "120"]}] -EOF -ok=1; for i in $(seq 1 20); do [ "$(j -n default pod pin-probe '{.status.phase}')" = Running ] && { ok=0; break; }; sleep 3; done -$K -n default delete pod pin-probe --wait=false >/dev/null 2>&1; exit $ok"""), - ], - solution=""" -k describe node {W1} | head -20 -# Taints: maintenance=inprogress:NoSchedule -# Unschedulable: true -k uncordon {W1} -k taint node {W1} maintenance- # trailing dash removes the taint -k get node {W1} -o jsonpath='{.spec.taints}{"\\n"}{.spec.unschedulable}{"\\n"}' -# Cordon and taints are two independent mechanisms - `kubectl drain` sets the cordon, -# and a taint may have been left by an operator or added by hand. Both must go. -""", -) - -# ------------------------------------------------------------------ 308 ---- -task( - id=308, domain="ARCH", title="API resource reference card", - task=""" -Write `{WS}/q308/resources.txt` with one line per resource below, in this exact format: - -``` -||| -``` - -for these four: `networkpolicies`, `persistentvolumes`, `storageclasses`, `certificatesigningrequests`. - -Take the values from the cluster (`kubectl api-resources`), not from memory. Use `-` when a -resource has no shortname. Lines in the order given above. -""", - setup=r"""rm -rf "$WS/q308"; mkdir -p "$WS/q308" """, - checks=[ - C("all four lines present and correctly formatted", 3, r""" -f="$WS/q308/resources.txt"; [ -f "$f" ] || exit 1 -for r in networkpolicies persistentvolumes storageclasses certificatesigningrequests; do grep -q "^$r|" "$f" || exit 1; done -[ "$(grep -c '|' "$f")" -ge 4 ]"""), - C("shortnames and namespaced flags correct", 4, r""" -f="$WS/q308/resources.txt" -grep -Eq '^networkpolicies\|netpol\|networking.k8s.io/v1\|true$' "$f" || exit 1 -grep -Eq '^persistentvolumes\|pv\|v1\|false$' "$f" || exit 1 -grep -Eq '^storageclasses\|sc\|storage.k8s.io/v1\|false$' "$f" || exit 1 -grep -Eq '^certificatesigningrequests\|csr\|certificates.k8s.io/v1\|false$' "$f" || exit 1"""), - C("order matches the task", 2, r""" -f="$WS/q308/resources.txt" -[ "$(grep -o '^[a-z]*' "$f" | grep . | head -4 | tr '\n' ' ')" = "networkpolicies persistentvolumes storageclasses certificatesigningrequests " ]"""), - ], - solution=""" -k api-resources | grep -E '^(networkpolicies|persistentvolumes|storageclasses|certificatesigningrequests) ' -# NAME SHORTNAMES APIVERSION NAMESPACED KIND -cat > {WS}/q308/resources.txt <<'EOF' -networkpolicies|netpol|networking.k8s.io/v1|true -persistentvolumes|pv|v1|false -storageclasses|sc|storage.k8s.io/v1|false -certificatesigningrequests|csr|certificates.k8s.io/v1|false -EOF -# k api-resources --namespaced=false narrows to cluster-scoped resources; -# k explain shows the same apiVersion when you're unsure. -""", -) - -# ------------------------------------------------------------------ 309 ---- -task( - id=309, domain="WORK", title="A PodDisruptionBudget that blocks maintenance", breaks="w3-drain", - task=""" -Namespace `project-atlas` runs Deployment `atlas-api` with 2 replicas, and a PodDisruptionBudget -`atlas-api` that currently makes voluntary eviction impossible. - -Node `{W3}` must be drained for maintenance, and `atlas-api` must keep at least one Pod serving -at all times. - -1. Adjust the PodDisruptionBudget so the drain can complete while still guaranteeing one - available Pod. Keep the PDB — do not delete it. -2. Drain `{W3}` (DaemonSet Pods aside), then make it schedulable again. -3. Write the value of the PDB's `.status.disruptionsAllowed` after your change to - `{WS}/q309/allowed.txt`. - -Look before you drain: `k get pods -A -o wide --field-selector spec.nodeName={W3}`. A Pod without a -controller blocks a plain drain, and `--force` deletes it for good — if it belongs to another -task, recreate it once the node is back. -""", - setup=NS + r""" -mkns project-atlas -rm -rf "$WS/q309"; mkdir -p "$WS/q309" -$K uncordon "$W3" >/dev/null 2>&1 || true -$K -n project-atlas delete deploy atlas-api --ignore-not-found >/dev/null -$K -n project-atlas delete pdb atlas-api --ignore-not-found >/dev/null -$K apply -f - >/dev/null <<'EOF' -apiVersion: apps/v1 -kind: Deployment -metadata: {name: atlas-api, namespace: project-atlas} -spec: - replicas: 2 - selector: {matchLabels: {app: atlas-api}} - template: - metadata: {labels: {app: atlas-api}} - spec: - containers: [{name: api, image: nginx:1.27, resources: {requests: {cpu: 10m, memory: 16Mi}}}] ---- -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: {name: atlas-api, namespace: project-atlas} -spec: - minAvailable: 2 - selector: {matchLabels: {app: atlas-api}} -EOF -$K -n project-atlas rollout status deploy atlas-api --timeout=120s >/dev/null 2>&1 -""", - checks=[ - C("PDB still exists and now permits exactly one disruption", 3, r""" -p="$K -n project-atlas get pdb atlas-api" -$p >/dev/null 2>&1 || exit 1 -ma=$($p -o jsonpath='{.spec.minAvailable}'); mu=$($p -o jsonpath='{.spec.maxUnavailable}') -{ [ "$ma" = 1 ] || [ "$mu" = 1 ]; }"""), - C("no atlas-api Pod left on the drained node", 3, r""" -n=$($K -n project-atlas get pod -l app=atlas-api -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' 2>/dev/null | grep -c "^$W3$") -eq "$n" 0"""), - C("both replicas Available and node schedulable again", 3, r""" -eq "$(j -n project-atlas deploy atlas-api '{.status.availableReplicas}')" 2 && [ "$(j node "$W3" '{.spec.unschedulable}')" != true ]"""), - C("allowed.txt records disruptionsAllowed", 1, r"""grep -Eq '^[0-9]+$' <(tr -dc '0-9\n' < "$WS/q309/allowed.txt" 2>/dev/null | grep .) """), - ], - solution=""" -k -n project-atlas get pdb atlas-api -# ALLOWED DISRUPTIONS: 0 -> minAvailable 2 with 2 replicas means nothing may be evicted; -# a drain would hang forever waiting for the eviction API. -k -n project-atlas patch pdb atlas-api -p '{"spec":{"minAvailable":1}}' -k -n project-atlas get pdb atlas-api # ALLOWED DISRUPTIONS: 1 -k get pods -A -o wide --field-selector spec.nodeName={W3} # bare pods here? they need --force and are then gone -k drain {W3} --ignore-daemonsets --delete-emptydir-data -k -n project-atlas rollout status deploy atlas-api -k uncordon {W3} -k -n project-atlas get pdb atlas-api -o jsonpath='{.status.disruptionsAllowed}' > {WS}/q309/allowed.txt -""", -) - -# ------------------------------------------------------------------ 310 ---- -task( - id=310, domain="NET", title="Route to something outside the cluster", - task=""" -An external database lives at `10.190.7.25` port 5432. Applications should reach it through a -normal in-cluster Service name. - -In Namespace `project-orbit`: -1. Create a Service `external-db` (ClusterIP, port 5432) that has **no selector**. -2. Make it resolve to that external address by creating the matching `EndpointSlice` - named `external-db-1` (port 5432, TCP, addressType IPv4) with the right labels so the - Service adopts it. -3. Write the ClusterIP of the Service to `{WS}/q310/ip.txt`. - -Do not use an ExternalName Service. -""", - setup=NS + r""" -mkns project-orbit -rm -rf "$WS/q310"; mkdir -p "$WS/q310" -$K -n project-orbit delete svc external-db --ignore-not-found >/dev/null -$K -n project-orbit delete endpointslice external-db-1 --ignore-not-found >/dev/null 2>&1 -""", - checks=[ - C("Service external-db is ClusterIP on 5432 with no selector", 3, r""" -s="$K -n project-orbit get svc external-db" -eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" ClusterIP && eq "$($s -o jsonpath='{.spec.ports[0].port}')" 5432 && [ -z "$($s -o jsonpath='{.spec.selector}')" ]"""), - C("EndpointSlice external-db-1 points at 10.190.7.25:5432", 4, r""" -e="$K -n project-orbit get endpointslice external-db-1" -eq "$($e -o jsonpath='{.addressType}' 2>/dev/null)" IPv4 && $e -o jsonpath='{.endpoints[*].addresses[*]}' | grep -q '10.190.7.25' \ - && eq "$($e -o jsonpath='{.ports[0].port}')" 5432"""), - C("the slice is linked to the Service by label", 2, r"""eq "$(j -n project-orbit endpointslice external-db-1 '{.metadata.labels.kubernetes\.io/service-name}')" external-db"""), - C("ip.txt records the Service ClusterIP", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q310/ip.txt" 2>/dev/null)" "$(j -n project-orbit svc external-db '{.spec.clusterIP}')" """), - ], - solution=""" -cat < {WS}/q310/ip.txt -k -n project-orbit get endpointslice -# ExternalName would return a CNAME instead and gives you no ClusterIP or port mapping. -""", -) - -# ------------------------------------------------------------------ 311 ---- -task( - id=311, domain="ARCH", title="Image inventory across the cluster", - task=""" -Produce `{WS}/q311/images.txt`: for every Deployment in every Namespace, one line - -``` -/ [,...] -``` - -with the images of its Pod template (init containers included), comma-separated in the order they -appear, and the file sorted by the first column. - -Then write the number of **distinct** images in use across those Deployments to -`{WS}/q311/distinct.txt`. -""", - setup=r"""rm -rf "$WS/q311"; mkdir -p "$WS/q311" """, - checks=[ - # Graded against the Deployments that existed when the file was written (its mtime): other - # set-B tasks create Deployments later (303's cidr-demo), and an exact cluster-wide - # comparison at submit time failed a correct answer whenever one of them came after this. - C("images.txt matches the cluster as it was when you wrote it", 5, r""" -f="$WS/q311/images.txt"; [ -s "$f" ] || exit 1 -$K get deploy -A -o json 2>/dev/null | python3 -c ' -import sys,json,os,datetime -f=sys.argv[1]; T=os.path.getmtime(f) -def ts(d): return datetime.datetime.strptime(d["metadata"]["creationTimestamp"],"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp() -want=set(); later=set() -for d in json.load(sys.stdin)["items"]: - key="%s/%s"%(d["metadata"]["namespace"],d["metadata"]["name"]); s=d["spec"]["template"]["spec"] - if ts(d) > T: later.add(key); continue - want.add("%s %s"%(key,",".join([c["image"] for c in s.get("initContainers",[])]+[c["image"] for c in s["containers"]]))) -got={l.strip() for l in open(f) if l.strip()} -extra={g for g in got-want if g.split(" ")[0] in {w.split(" ")[0] for w in want}} # a real deployment, wrong images -sys.exit(0 if want<=got and not extra else 1)' "$f" """), - C("distinct.txt has the right count", 3, r""" -f="$WS/q311/images.txt"; [ -s "$f" ] || exit 1 -want=$($K get deploy -A -o json 2>/dev/null | python3 -c ' -import sys,json,os,datetime -T=os.path.getmtime(sys.argv[1]); s=set() -for d in json.load(sys.stdin)["items"]: - if datetime.datetime.strptime(d["metadata"]["creationTimestamp"],"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp() > T: continue - sp=d["spec"]["template"]["spec"] - for c in sp.get("initContainers",[])+sp["containers"]: s.add(c["image"]) -print(len(s))' "$f") -eq "$(tr -dc '0-9' < "$WS/q311/distinct.txt" 2>/dev/null)" "$want" """), - ], - solution=""" -k get deploy -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{" "}{range .spec.template.spec.initContainers[*]}{.image}{","}{end}{range .spec.template.spec.containers[*]}{.image}{","}{end}{"\\n"}{end}' \\ - | sed 's/,$//' | sort > {WS}/q311/images.txt -k get deploy -A -o jsonpath='{range .items[*]}{range .spec.template.spec.initContainers[*]}{.image}{"\\n"}{end}{range .spec.template.spec.containers[*]}{.image}{"\\n"}{end}{end}' \\ - | grep . | sort -u | wc -l > {WS}/q311/distinct.txt -# Two `range` blocks over the same pod spec keeps init and normal containers in order. -# A trailing comma is easiest to strip afterwards with sed. -""", -) - -# ------------------------------------------------------------------ 312 ---- -task( - id=312, domain="WORK", title="A Job that fails, then one that doesn't", - task=""" -In Namespace `project-orion`: - -1. Job `flaky` exists and keeps failing. Let it reach its terminal state, then write the name of - the condition that marks it failed and its reason, as `/`, to `{WS}/q312/why.txt`. -2. Create a **new** Job `steady` that does the same work correctly: image `busybox:1.36`, - 6 completions, 3 in parallel, `backoffLimit: 2`, restartPolicy Never, each Pod printing - `ok` and exiting 0. -3. `steady` must complete all 6. -""", - setup=NS + r""" -mkns project-orion -rm -rf "$WS/q312"; mkdir -p "$WS/q312" -$K -n project-orion delete job flaky steady --ignore-not-found >/dev/null -$K apply -f - >/dev/null <<'EOF' -apiVersion: batch/v1 -kind: Job -metadata: {name: flaky, namespace: project-orion} -spec: - completions: 2 - parallelism: 1 - backoffLimit: 1 - template: - spec: - restartPolicy: Never - containers: [{name: c, image: busybox:1.36, command: [sh, -c, "echo starting; exit 7"]}] -EOF -""", - checks=[ - C("why.txt names the Failed condition and BackoffLimitExceeded", 3, r""" -f="$WS/q312/why.txt"; grep -qi 'Failed' "$f" 2>/dev/null && grep -qi 'BackoffLimitExceeded' "$f" """), - C("Job steady: 6 completions, parallelism 3, backoffLimit 2, restartPolicy Never", 3, r""" -j1="$K -n project-orion get job steady" -eq "$($j1 -o jsonpath='{.spec.completions}' 2>/dev/null)" 6 && eq "$($j1 -o jsonpath='{.spec.parallelism}')" 3 \ - && eq "$($j1 -o jsonpath='{.spec.backoffLimit}')" 2 && eq "$($j1 -o jsonpath='{.spec.template.spec.restartPolicy}')" Never"""), - C("steady completed all 6", 4, r"""for i in $(seq 1 20); do [ "$(j -n project-orion job steady '{.status.succeeded}')" = 6 ] && exit 0; sleep 3; done; exit 1"""), - ], - solution=""" -k -n project-orion get job flaky -o yaml | grep -A6 conditions -# type: Failed / reason: BackoffLimitExceeded -k -n project-orion describe job flaky -k -n project-orion logs job/flaky # exit 7 -echo Failed/BackoffLimitExceeded > {WS}/q312/why.txt -k -n project-orion create job steady --image=busybox:1.36 $do -- sh -c "echo ok" > steady.yaml -# set spec.completions: 6, spec.parallelism: 3, spec.backoffLimit: 2 -k apply -f steady.yaml -k -n project-orion wait --for=condition=complete job/steady --timeout=120s -""", -) - -# ------------------------------------------------------------------ 313 ---- -task( - id=313, domain="NET", title="Two hosts, TLS, and a default backend", - task=""" -Namespace `project-r700` has Services `web-classic` and `web-next` (port 80). - -Create Ingress `multi` in that Namespace with class `nginx`: - -- host `classic.r700.io`, path `/` (Prefix) → `web-classic:80`, TLS with secret `classic-tls` -- host `next.r700.io`, path `/` (Prefix) → `web-next:80`, TLS with the same secret -- a **default backend** (requests matching no rule) → `web-classic:80` - -Also create the TLS secret `classic-tls` from a self-signed certificate whose CN is `classic.r700.io`. -""", - setup=NS + r""" -mkns project-r700 -for s in web-classic web-next; do - $K -n project-r700 create deploy $s --image=nginx:1.27 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null - $K -n project-r700 expose deploy $s --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null -done -$K -n project-r700 delete ingress multi --ignore-not-found >/dev/null -$K -n project-r700 delete secret classic-tls --ignore-not-found >/dev/null -""", - checks=[ - C("Secret classic-tls is a TLS secret with CN classic.r700.io", 3, r""" -eq "$(j -n project-r700 secret classic-tls '{.type}')" kubernetes.io/tls \ - && j -n project-r700 secret classic-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'CN *= *classic.r700.io'"""), - C("both host rules route to the right Services on /", 3, r""" -$K -n project-r700 get ingress multi -o json 2>/dev/null | python3 -c ' -import sys,json -rs=json.load(sys.stdin)["spec"]["rules"] -m={r["host"]:r["http"]["paths"][0] for r in rs if r.get("host")} -ok=all(h in m and m[h]["pathType"]=="Prefix" and m[h]["path"]=="/" for h in ("classic.r700.io","next.r700.io")) -ok=ok and m["classic.r700.io"]["backend"]["service"]["name"]=="web-classic" and m["next.r700.io"]["backend"]["service"]["name"]=="web-next" -sys.exit(0 if ok else 1)'"""), - C("TLS covers both hosts with classic-tls", 2, r""" -$K -n project-r700 get ingress multi -o json 2>/dev/null | python3 -c ' -import sys,json -t=json.load(sys.stdin)["spec"].get("tls",[]) -hosts={h for e in t for h in e.get("hosts",[])}; secs={e.get("secretName") for e in t} -sys.exit(0 if {"classic.r700.io","next.r700.io"}<=hosts and secs=={"classic-tls"} else 1)'"""), - C("defaultBackend points at web-classic:80, class is nginx", 3, r""" -i="$K -n project-r700 get ingress multi" -eq "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" nginx \ - && eq "$($i -o jsonpath='{.spec.defaultBackend.service.name}')" web-classic && eq "$($i -o jsonpath='{.spec.defaultBackend.service.port.number}')" 80"""), - ], - solution=""" -openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=classic.r700.io" -keyout t.key -out t.crt -k -n project-r700 create secret tls classic-tls --cert=t.crt --key=t.key -cat < "$d/edge-cache.yaml" < is Running with nginx:1.27", 5, r""" -for i in $(seq 1 12); do - [ "$(j -n default pod "edge-cache-$W3" '{.status.phase}')" = Running ] && [ "$(j -n default pod "edge-cache-$W3" '{.spec.containers[0].image}')" = nginx:1.27 ] && exit 0 - sleep 5 -done; exit 1"""), - # -h: with two file operands GNU grep prefixes every line with the file name, which made - # awk's $2 the literal word "path:" and this check unpassable. - C("the hostPath the manifest references now exists on the node", 3, r""" -p=$(nx "$W3" 'grep -h -A2 hostPath /etc/kubernetes/manifests/edge-cache.yaml /etc/kubernetes/static/edge-cache.yaml 2>/dev/null | grep "path:" | head -1' | awk '{print $2}') -[ -n "$p" ] && nx "$W3" "test -d $p" """), - C("reason.txt mentions the missing host directory", 2, r"""grep -Eqi 'director|hostpath|/opt/edge-cache|not exist|missing' "$WS/q314/reason.txt" 2>/dev/null"""), - ], - solution=""" -k get pod -A | grep edge-cache # nothing -{SHELL_W3} -journalctl -u kubelet -n 40 --no-pager | grep -i edge-cache -# ... failed to start ... hostPath type check failed: /opt/edge-cache/conf is not a directory -grep staticPodPath /var/lib/kubelet/config.yaml -cat /edge-cache.yaml # hostPath /opt/edge-cache/conf with type: Directory -mkdir -p /opt/edge-cache/conf # type: Directory means the kubelet will NOT create it -exit -k get pod edge-cache-{W3} -echo 'hostPath /opt/edge-cache/conf did not exist and type was Directory' > {WS}/q314/reason.txt -# The alternative fix is type: DirectoryOrCreate, but the task asks for the pod to run as written. -""", -) - -# ------------------------------------------------------------------ 315 ---- -task( - id=315, domain="STOR", title="Keep the data when the claim goes", - task=""" -In Namespace `project-orbit`, Pod `keeper` writes to PVC `keeper-claim`, which was provisioned -dynamically by the default StorageClass — whose reclaim policy is `Delete`. - -The data must survive the claim being deleted: - -1. Without recreating anything, make sure the bound PersistentVolume is **retained** when its claim - goes away. -2. Write the PV's name to `{WS}/q315/pv.txt`. -3. Delete Pod `keeper` and PVC `keeper-claim`. -4. The PV must still exist afterwards, in phase `Released`. -""", - setup=NS + r""" -mkns project-orbit -rm -rf "$WS/q315"; mkdir -p "$WS/q315" -$K -n project-orbit delete pod keeper --ignore-not-found --wait=true >/dev/null -$K -n project-orbit delete pvc keeper-claim --ignore-not-found >/dev/null -$K apply -f - >/dev/null <<'EOF' -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: {name: keeper-claim, namespace: project-orbit} -spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 200Mi}}} ---- -apiVersion: v1 -kind: Pod -metadata: {name: keeper, namespace: project-orbit} -spec: - volumes: [{name: d, persistentVolumeClaim: {claimName: keeper-claim}}] - containers: [{name: c, image: busybox:1.36, command: [sh, -c, "echo important > /data/keep.txt; sleep 86400"], volumeMounts: [{name: d, mountPath: /data}]}] -EOF -for i in $(seq 1 30); do [ "$(j -n project-orbit pod keeper '{.status.phase}')" = Running ] && break; sleep 2; done -""", - checks=[ - C("pv.txt names the PV that was bound to keeper-claim", 3, r""" -p=$(tr -d '[:space:]' < "$WS/q315/pv.txt" 2>/dev/null); [ -n "$p" ] || exit 1 -eq "$(j pv "$p" '{.spec.claimRef.name}')" keeper-claim"""), - C("that PV has reclaim policy Retain", 4, r"""eq "$(j pv "$(tr -d '[:space:]' < "$WS/q315/pv.txt")" '{.spec.persistentVolumeReclaimPolicy}')" Retain"""), - C("Pod and PVC are gone", 2, r"""! $K -n project-orbit get pod keeper >/dev/null 2>&1 && ! $K -n project-orbit get pvc keeper-claim >/dev/null 2>&1"""), - C("the PV still exists and is Released", 3, r""" -p=$(tr -d '[:space:]' < "$WS/q315/pv.txt"); for i in $(seq 1 10); do [ "$(j pv "$p" '{.status.phase}')" = Released ] && exit 0; sleep 3; done; exit 1"""), - ], - solution=""" -k -n project-orbit get pvc keeper-claim -o jsonpath='{.spec.volumeName}' | tee {WS}/q315/pv.txt -PV=$(cat {WS}/q315/pv.txt) -k patch pv $PV -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' -k get pv $PV # RECLAIM POLICY: Retain -k -n project-orbit delete pod keeper --now -k -n project-orbit delete pvc keeper-claim -k get pv $PV # STATUS: Released - data still on disk -# Patch the PV, not the StorageClass: the class only sets the policy for volumes created later. -# To reuse it, clear .spec.claimRef and it becomes Available again. -""", -) - -# ------------------------------------------------------------------ 316 ---- -task( - id=316, domain="ARCH", title="Plan a control-plane upgrade", - task=""" -The control-plane node must be upgraded to the next patch release. - -1. Write the cluster's current control-plane version to `{WS}/q316/current.txt` (as `kubectl` reports it). -2. Run the kubeadm command that shows what an upgrade would do, and save its output to - `{WS}/q316/plan.txt`. -3. Write to `{WS}/q316/steps.sh` the ordered commands you would run to upgrade the control-plane - node — package install of kubeadm, the kubeadm upgrade step, draining, upgrading kubelet and - kubectl, restarting the kubelet, and uncordoning — one command per line, in the order they must - be executed. Do **not** execute them. -""", - setup=r"""rm -rf "$WS/q316"; mkdir -p "$WS/q316" """, - checks=[ - C("current.txt matches the server version", 2, r""" -v=$($K version -o json 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])') -grep -qF "$v" "$WS/q316/current.txt" 2>/dev/null"""), - C("plan.txt is real kubeadm upgrade plan output", 3, r"""grep -Eqi 'upgrade|component|version' "$WS/q316/plan.txt" 2>/dev/null && [ "$(wc -l < "$WS/q316/plan.txt")" -ge 5 ]"""), - C("steps.sh has the commands in the correct order", 5, r""" -f="$WS/q316/steps.sh"; [ -f "$f" ] || exit 1 -grep -q 'kubeadm upgrade apply' "$f" || exit 1 -grep -Eq '(apt|yum|dnf).*(install).*kubeadm' "$f" || exit 1 -grep -q 'drain' "$f" && grep -q 'uncordon' "$f" || exit 1 -grep -Eq '(apt|yum|dnf).*(install).*kubelet' "$f" || exit 1 -grep -Eq 'systemctl (restart|daemon-reload)' "$f" || exit 1 -n=$(grep -n 'kubeadm upgrade apply' "$f" | head -1 | cut -d: -f1) -ka=$(grep -nE '(apt|yum|dnf).*install.*kubeadm' "$f" | head -1 | cut -d: -f1) -kl=$(grep -nE '(apt|yum|dnf).*install.*kubelet' "$f" | head -1 | cut -d: -f1) -dr=$(grep -n 'drain' "$f" | head -1 | cut -d: -f1) -un=$(grep -n 'uncordon' "$f" | head -1 | cut -d: -f1) -[ "$ka" -lt "$n" ] && [ "$n" -lt "$kl" ] && [ "$dr" -lt "$kl" ] && [ "$kl" -lt "$un" ]"""), - ], - solution=""" -k version -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])' > {WS}/q316/current.txt -{SHELL_CP} -kubeadm upgrade plan | tee /tmp/plan.txt # copy into {WS}/q316/plan.txt -cat > {WS}/q316/steps.sh <<'EOF' -apt-get update && apt-get install -y kubeadm=1.34.1-1.1 -kubeadm upgrade plan -kubeadm upgrade apply v1.34.1 -kubectl drain --ignore-daemonsets -apt-get install -y kubelet=1.34.1-1.1 kubectl=1.34.1-1.1 -systemctl daemon-reload -systemctl restart kubelet -kubectl uncordon -EOF -# Order matters and is the point of this task: kubeadm binary first, then `kubeadm upgrade apply` -# (it upgrades the static pod control plane), then drain, then kubelet/kubectl packages, restart, -# uncordon. Workers use `kubeadm upgrade node` instead of `upgrade apply`. -""", -) - -# ------------------------------------------------------------------ 317 ---- -# The sabotage switches off ONE controller. It used to point --cluster-signing-cert-file at a -# missing file instead, but a controller that fails to start is fatal to kube-controller-manager -# (verified on 1.37: "Error building controllers" -> exit), so the whole KCM crash-looped and every -# other set-B task that needs a controller (Deployments, Jobs, EndpointSlices, PV phases, PDB -# status, token Secrets) silently stopped working until this task - the last one - was fixed. -task( - id=317, domain="TSHOOT", title="Certificates stop being issued", breaks="controller-manager", - task=""" -Certificate signing requests are approved but never get a certificate: `kubectl get csr` shows -them `Approved` and never `Approved,Issued`. Everything else in the cluster works. - -Find the responsible control-plane component, repair it on the control-plane node, and prove it -works: a CertificateSigningRequest named `issue-probe` (CN `probe`, signer -`kubernetes.io/kube-apiserver-client`, usage `client auth`) that you create and approve must -receive a certificate. -""", - setup=r""" -$K delete csr issue-probe --ignore-not-found >/dev/null 2>&1 -nx "$CP" ' -f=/etc/kubernetes/manifests/kube-controller-manager.yaml -grep -q -- "-certificatesigningrequest-signing-controller" $f || sed -i "s|^\( *- --controllers=.*\)$|\1,-certificatesigningrequest-signing-controller|" $f -' -""", - checks=[ - C("kube-controller-manager runs the CSR signing controller again", 4, r""" -c=$(nx "$CP" 'grep -o -- "--controllers=[^ ]*" /etc/kubernetes/manifests/kube-controller-manager.yaml') -[ -n "$c" ] && ! echo "$c" | grep -Eq -- '-(certificatesigningrequest-signing-controller|csrsigning)(,|$)'"""), - C("kube-controller-manager still Running after your edit", 3, r""" -for i in $(seq 1 12); do [ "$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].status.phase}')" = Running ] && exit 0; sleep 5; done; exit 1"""), - C("an approved CSR now receives a certificate", 5, r""" -$K get csr issue-probe >/dev/null 2>&1 || exit 1 -$K get csr issue-probe -o jsonpath='{.status.conditions[*].type}' | grep -q Approved || exit 1 -for i in $(seq 1 15); do [ -n "$(j csr issue-probe '{.status.certificate}')" ] && exit 0; sleep 4; done; exit 1"""), - ], - solution=""" -k get csr # Approved - but never Approved,Issued -# Approving is the API's job; ISSUING is a controller's job, and controllers live in -# kube-controller-manager. So: is it running, and is the right controller in it running? -k -n kube-system get pod -l component=kube-controller-manager # Running - so not a crash -k -n kube-system logs kube-controller-manager-{CP} | grep -i disabled -# "Warning: controller is disabled" controller="certificatesigningrequest-signing-controller" -{SHELL_CP} -grep -- --controllers /etc/kubernetes/manifests/kube-controller-manager.yaml -# --controllers=*,bootstrapsigner,tokencleaner,-certificatesigningrequest-signing-controller -vi /etc/kubernetes/manifests/kube-controller-manager.yaml # remove ,-certificatesigningrequest-signing-controller -exit -k -n kube-system get pod -l component=kube-controller-manager -w # the kubelet restarts the static pod -openssl genrsa -out probe.key 2048 -openssl req -new -key probe.key -subj "/CN=probe" -out probe.csr -cat </default: serviceaccount "default" not found - # Setups that create a namespace and immediately apply Pods lose that race under load (Q204), + # Setups that create a namespace and immediately apply Pods lose that race under load, # which fails the seed and costs the candidate a whole task. It belongs here, not in each task. for _ in $(seq 1 30); do $K -n "$1" get sa default >/dev/null 2>&1 && return 0; sleep 1; done } diff --git a/tracks/cka/questions.py b/tracks/cka/questions.py index 4f98395..aa509d3 100644 --- a/tracks/cka/questions.py +++ b/tracks/cka/questions.py @@ -23,8 +23,9 @@ TRACK = { # tasks per domain drawn into a session (weights follow the exam blueprint) "quota": {"ARCH": 5, "WORK": 4, "NET": 4, "STOR": 2, "TSHOOT": 5}, # 20 tasks "quota_hard": {"ARCH": 6, "WORK": 5, "NET": 5, "STOR": 3, "TSHOOT": 6}, # 25 tasks, hard ones preferred - # fixed simulator sets (./exam sim a|b|c|d) - excluded from random sampling - "sets": {"a": "Simulator Set A", "b": "Simulator Set B", "c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"}, + # fixed simulator sets (./exam sim c|d) - excluded from random sampling. Sets A and B were + # retired on 2026-09-22; their last (fixed, verified) versions are in git history. + "sets": {"c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"}, "domains": { "ARCH": "Cluster Architecture, Installation & Configuration (25%)", "WORK": "Workloads & Scheduling (15%)", @@ -631,10 +632,10 @@ $K -n default delete pod sched-probe --wait=false >/dev/null 2>&1 ] # ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 101-124 (hard) in bank_hard, -# ---- 201-217 / 301-317 / 401-418 / 501-517 in the four fixed simulator sets -from tracks.cka import bank_extra, bank_hard, bank_sim_a, bank_sim_b, bank_sim_c, bank_sim_d +# ---- 401-418 / 501-517 in the two fixed simulator sets +from tracks.cka import bank_extra, bank_hard, bank_sim_c, bank_sim_d QUESTIONS = (QUESTIONS + bank_extra.QUESTIONS + bank_hard.QUESTIONS - + bank_sim_a.QUESTIONS + bank_sim_b.QUESTIONS + bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS) + + bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS) QUESTIONS.sort(key=lambda q: q["id"]) _ids = [q["id"] for q in QUESTIONS] assert len(_ids) == len(set(_ids)), "duplicate question ids" diff --git a/weak-spots-cka.md b/weak-spots-cka.md index 70869fd..d196b58 100644 --- a/weak-spots-cka.md +++ b/weak-spots-cka.md @@ -100,27 +100,6 @@ _Generated by exam from 10 attempt(s); regenerated on every `end`._ - ✗ Service ledger-21 port 9000 in the same namespace - ✗ ns.txt has the namespace -### Q202 · ARCH · Helm chart with CRDs, then a custom resource (1/1 failed, last 3/8 pts, last failed 2026-09-19T12:27) -- ✗ watchAllNamespaces=true reached the pod env -- ✗ Ledger main-ledger has currency USD and retentionDays 30 - -### Q204 · WORK · Which Pods get evicted first (1/1 failed, last 0/8 pts, last failed 2026-09-19T12:27) -- ✗ evicted-first.txt lists exactly the BestEffort pods -- ✗ api-qos.txt says Burstable -- ✗ guaranteed.txt names orion-ledger - -### Q217 · TSHOOT · Inspect a container with crictl (1/1 failed, last 0/10 pts, last failed 2026-09-19T12:27) -- ✗ Pod tiger-probe Running with both labels -- ✗ container.txt holds a real container ID for that Pod -- ✗ container.txt records the runtimeType -- ✗ container.log holds the httpd startup output - -### Q313 · NET · Two hosts, TLS, and a default backend (1/1 failed, last 0/11 pts, last failed 2026-09-19T12:27) -- ✗ Secret classic-tls is a TLS secret with CN classic.r700.io -- ✗ both host rules route to the right Services on / -- ✗ TLS covers both hosts with classic-tls -- ✗ defaultBackend points at web-classic:80, class is nginx - ## Attempt matrix | Q | Domain | Title | D1 | E2 | D3 | D4 | D5 | D6 | D7 | D8 | D9 | D10 | @@ -192,40 +171,6 @@ _Generated by exam from 10 attempt(s); regenerated on every `end`._ | 122 | ARCH | Renew control-plane certificates | · | · | · | · | · | · | · | · | · | · | | 123 | TSHOOT | Nothing works: kube-apiserver itself is down | · | · | · | · | · | · | · | · | · | · | | 124 | ARCH | Prepare a node join without shell access to the control plane | · | · | · | · | · | · | · | · | · | · | -| 201 | ARCH | kubeconfig forensics | · | · | · | · | · | · | · | · | · | · | -| 202 | ARCH | Helm chart with CRDs, then a custom resource | · | · | · | · | · | · | · | · | · | 3/8 | -| 203 | WORK | Find the controller, then scale it | · | · | · | · | · | · | · | · | · | · | -| 204 | WORK | Which Pods get evicted first | · | · | · | · | · | · | · | · | · | 0/8 | -| 205 | ARCH | Kustomize: drop a ConfigMap, add an HPA, override in prod | · | · | · | · | · | · | · | · | · | · | -| 206 | STOR | PV, PVC and a Deployment that mounts it | · | · | · | · | · | · | · | · | · | · | -| 207 | TSHOOT | Resource usage scripts | · | · | · | · | · | · | · | · | · | · | -| 208 | ARCH | Drain a node for maintenance | · | · | · | · | · | · | · | · | · | · | -| 209 | ARCH | Query the API from inside a Pod | · | · | · | · | · | · | · | · | · | · | -| 210 | ARCH | ServiceAccount limited to two resources | · | · | · | · | · | · | · | · | · | · | -| 211 | WORK | DaemonSet on every node | · | · | · | · | · | · | · | · | · | · | -| 212 | WORK | One Pod per node, on purpose | · | · | · | · | · | · | · | · | · | · | -| 213 | NET | Convert an Ingress to Gateway API, then split on a header | · | · | · | · | · | · | · | · | · | · | -| 214 | ARCH | Certificate expiry, two ways | · | · | · | · | · | · | · | · | · | · | -| 215 | NET | Egress policy with paired destinations | · | · | · | · | · | · | · | · | · | · | -| 216 | NET | CoreDNS: back up, extend, verify | · | · | · | · | · | · | · | · | · | · | -| 217 | TSHOOT | Inspect a container with crictl | · | · | · | · | · | · | · | · | · | 0/10 | -| 301 | ARCH | etcd configuration facts | · | · | · | · | · | · | · | · | · | ✓ | -| 302 | NET | Service rules on the node | · | · | · | · | · | · | · | · | · | · | -| 303 | NET | A second Service CIDR | · | · | · | · | · | · | · | · | · | · | -| 304 | ARCH | Bound tokens versus legacy tokens | · | · | · | · | · | · | · | · | · | · | -| 305 | WORK | Schedule without the scheduler | · | · | · | · | · | · | · | · | · | · | -| 306 | STOR | Grow a volume in place | · | · | · | · | · | · | · | · | · | · | -| 307 | TSHOOT | Node accepts nothing new | · | · | · | · | · | · | · | · | · | · | -| 308 | ARCH | API resource reference card | · | · | · | · | · | · | · | · | · | · | -| 309 | WORK | A PodDisruptionBudget that blocks maintenance | · | · | · | · | · | · | · | · | · | · | -| 310 | NET | Route to something outside the cluster | · | · | · | · | · | · | · | · | · | · | -| 311 | ARCH | Image inventory across the cluster | · | · | · | · | · | · | · | · | · | · | -| 312 | WORK | A Job that fails, then one that doesn't | · | · | · | · | · | · | · | · | · | · | -| 313 | NET | Two hosts, TLS, and a default backend | · | · | · | · | · | · | · | · | · | 0/11 | -| 314 | TSHOOT | A static pod that never starts | · | · | · | · | · | · | · | · | · | · | -| 315 | STOR | Keep the data when the claim goes | · | · | · | · | · | · | · | · | · | · | -| 316 | ARCH | Plan a control-plane upgrade | · | · | · | · | · | · | · | · | · | · | -| 317 | TSHOOT | Certificates stop being issued | · | · | · | · | · | · | · | · | · | · | | 401 | STOR | Provision geo-fence persistent storage | · | · | · | · | · | · | · | · | · | · | | 402 | ARCH | Render a chart, then install it | · | · | · | · | · | · | · | · | · | · | | 403 | TSHOOT | Fix the driver onboarding app | · | · | · | · | · | · | · | · | · | · | @@ -244,5 +189,22 @@ _Generated by exam from 10 attempt(s); regenerated on every `end`._ | 416 | NET | NetworkPolicy for the safety monitor | · | · | · | · | · | · | · | · | · | · | | 417 | ARCH | Enforce TLS 1.3 on the booking portal | · | · | · | · | · | · | · | · | · | · | | 418 | TSHOOT | Investigate vehicle tracker health | · | · | · | · | · | · | · | · | · | · | +| 501 | ARCH | How the control plane is run | · | · | · | · | · | · | · | · | · | · | +| 502 | TSHOOT | Cluster facts to an exact file | · | · | · | · | · | · | · | · | · | · | +| 503 | NET | Ready only when its dependency answers | · | · | · | · | · | · | · | · | · | · | +| 504 | WORK | Sorting done by kubectl | · | · | · | · | · | · | · | · | · | · | +| 505 | TSHOOT | Event forensics around kube-proxy | · | · | · | · | · | · | · | · | · | · | +| 506 | ARCH | API resources and a Role census | · | · | · | · | · | · | · | · | · | · | +| 507 | TSHOOT | Kubelet unit points at nothing | · | · | · | · | · | · | · | · | · | · | +| 508 | WORK | Secrets as a read-only volume and renamed env vars | · | · | · | · | · | · | · | · | · | · | +| 509 | ARCH | Static Pod on the control plane behind a Service | · | · | · | · | · | · | · | · | · | · | +| 510 | ARCH | The kubelet's two certificates | · | · | · | · | · | · | · | · | · | · | +| 511 | WORK | A Pod for the control plane only | · | · | · | · | · | · | · | · | · | · | +| 512 | TSHOOT | Stop the scheduler, schedule by hand | · | · | · | · | · | · | · | · | · | · | +| 513 | ARCH | Helm release housekeeping | · | · | · | · | · | · | · | · | · | · | +| 514 | STOR | Per-replica storage for a StatefulSet | · | · | · | · | · | · | · | · | · | · | +| 515 | NET | Namespace AND pod selector, enforced | · | · | · | · | · | · | · | · | · | · | +| 516 | NET | Resolves everything except the cluster | · | · | · | · | · | · | · | · | · | · | +| 517 | STOR | A claim that can never bind | · | · | · | · | · | · | · | · | · | · | _E = full exam, D = drill, · = not in that session, n/m = partial credit._ diff --git a/web/index.html b/web/index.html index ba72daf..8ecdbca 100644 --- a/web/index.html +++ b/web/index.html @@ -347,8 +347,8 @@ const eligible = S.bank.filter(q => hard || q.difficulty !== 'hard'), seenN = eligible.filter(q => (S.seen || []).includes(q.id)).length; $('#allDesc').textContent = `${seenN}/${eligible.length} tasks attempted. 5 unseen tasks per session, round-robin across domains${hard ? ', hard first' : ''}; then whatever you failed.`; $('#nextDesc').textContent = `${S.track.domains[nxt]} — 5 tasks, ${hard ? 5 : 6} min each. Drills rotate through all domains before the exam.`; - // one button per fixed set the track defines (Set C and later were unreachable from here when - // the buttons were hard-coded to A and B) + // one button per fixed set the track defines - never hard-code the letters: newer sets used to + // be unreachable from here $('#simOpts').innerHTML = Object.entries(S.sets || {}).map(([k, info]) => ``).join(''); $('#simOpts').querySelectorAll('[data-sim]').forEach(el => el.onclick = () => startSim(el.dataset.sim));