feat: add CKA/CKAD/CKS exam simulator and question banks

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
vumast3r 2026-09-06 19:06:10 -06:00
parent 936ba69b05
commit dece60fd8a
25 changed files with 7918 additions and 1 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.state/
*.pyc
__pycache__/
.DS_Store

137
README.md
View File

@ -1,2 +1,137 @@
# cka-sim
# cka-sim — Kubernetes certification exam simulator
Entry point is `./exam` (renamed from `cka-sim` so it can't collide with the directory name).
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** (65-task bank: 43 normal + 22 hard),
CKAD and CKS (scaffolded — same format, add questions when you get there).
## How a session is built
Every session draws **fresh** tasks: `start` samples per domain in the exam's proportions
(ARCH 5, WORK 4, NET 4, STOR 2, TSHOOT 5 = 20), preferring tasks you have never seen, then the
least-recently seen. Most tasks are parameterized — namespaces, names, replica counts, ports,
which node is broken — so even a repeat isn't the same task. `drill` with no arguments picks the
domain you've drilled least and gives you 5 tasks from it; a full exam is gated until every
domain has been drilled at least once (`--force` skips that).
**Hard mode** (`--hard`, or the toggle in the UI lobby) is modelled on what people report about
killer.sh, not on its content: 25 tasks in the same 120 minutes, hard tasks preferred, no hints
about paths or nodes, traps (an over-granting binding you must find, an allow-all NetworkPolicy
left behind, a relocated `staticPodPath`, two PVs where only one is right), exact-output tasks
graded byte-for-byte, compound failures (three faults on one node, two control-plane components
down for different reasons, DNS broken three ways), collateral-damage checks (still cordoned,
recreated instead of fixed in place, extra PVs), and exam-style contexts `k8s-c1-H` /
`k8s-c2-AC` / `k8s-c3-CCC` with different default namespaces — each task says which to use.
Grading always runs against the real cluster context regardless of which one you're on.
## Why kind and not k3s / hosted labs
kind is kubeadm-based: real etcd, static-pod control plane, systemd kubelet on every node,
`kubeadm certs` on the control plane. That is what lets the troubleshooting tasks break real
things (kubelet, scheduler manifest) the way the exam does. Hosted labs and CK-X give you no
node access, which rules out exactly those tasks. `podman exec -it <node> bash` plays the role
of `ssh <node>` here; use the Vagrant/ssh mode below if you want a real ssh session.
## Requirements
podman, kind, kubectl, python3, **ttyd** (for the terminal pane in the browser UI).
~4 GB RAM for the 4-node cluster. Rootless podman needs cgroup v2; otherwise `sudo -E ./setup.sh`.
Docker still works: `CONTAINER_RUNTIME=docker ./setup.sh`.
## Run
```
./setup.sh # create the kind cluster, preload images (idempotent)
./exam web # open http://127.0.0.1:8080 — start an exam or drill from there
```
Both the built-in terminal and your own shell can have the exam aliases:
```
source ./examrc # bash: k=kubectl, $do, $now, kubectl completion (also for k), vim defaults
source ./examrc.fish # fish equivalent
```
`./exam web` sources the right one automatically for its terminal pane.
That's the whole loop. The UI seeds the scenarios, runs the clock, lets you flag questions and
keep notes, submits (or auto-submits at 00:00), shows the score with every check, and offers
"Drill what I missed". The terminal pane is a real `bash` via ttyd with kubectl already pointed
at the cluster (`--shell fish` if you'd rather practise in fish; the real exam is bash).
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
./exam start [--hard] # full exam (gated until all domains drilled; --force)
./exam list | show N | timer | status # during
./exam end # submit
./exam weak | history # afterwards
./exam --track ckad web # other tracks
./teardown.sh # delete cluster + state (history kept)
```
## Scoring, history, drills
Each task is a list of named, weighted checks (`tracks/cka/questions.py`). You get the points
for every check that passes, so a task can score 4/6 — the same partial-credit model as the
exam. Every submit appends to `history-<track>.jsonl` and regenerates `weak-spots-<track>.md`:
latest domain scores, a "drill next" section listing the *failing checks* per task, and a
✓ / n-of-m matrix across all attempts.
A task counts as weak if it failed most recently, has failed twice ever, or was never
attempted. Drills seed only those tasks, give 6 minutes per task, and — unlike exam mode —
let you check a task while you work. A task stops being weak only when you pass it again
under the clock.
## Using your own cluster instead of kind
Any kubeadm-style cluster with 1 control-plane + 3 workers works. Skip `setup.sh`, set (Fish):
```
set -x CKA_CONTEXT kubernetes-admin@kubernetes
set -x CKA_NODE_ACCESS ssh # podman | docker | ssh | vagrant
set -x CKA_CP cp1; set -x CKA_W1 node1; set -x CKA_W2 node2; set -x CKA_W3 node3
./exam web
```
`ssh` needs passwordless `ssh <node-name>` (for Vagrant: `vagrant ssh-config >> ~/.ssh/config`);
`vagrant` runs `vagrant ssh` from the Vagrantfile directory. Tasks 2 and 20 write to
`/opt/cka` and `/etc/kubernetes/manifests`, so the login must be able to. Managed clusters
(EKS/AKS/GKE) won't work — no node or control-plane access. Startup preflights all of this.
## CKA bank
| Domain | Normal (43) | Hard (22) |
|---|---|---|
| Architecture | RBAC, etcd snapshot, Kustomize, CRD, static pod, certs/CSR, Helm, aggregated ClusterRole, `kubectl explain` on a CRD, kubeadm join token, component versions | exact RBAC with an over-grant to remove, **etcd restore**, static pod on a relocated path, user → self-contained kubeconfig, Kustomize generators + patches, CRD validation/defaults, renew all control-plane certs |
| Workloads | rolling update, sidecar, node affinity + resources, ConfigMap/Secret, metrics-server repair + HPA, PriorityClass, taints, DaemonSet, CronJob, init container, immutable ConfigMap rotation, parallel Job, quota + LimitRange | anti-affinity + toleration + priority under a quota, rollback to a specific revision then pause, native sidecar + probes + exact report, cluster audit to exact files, work in the context's namespace |
| Networking | NetworkPolicy, NodePort, Ingress, cluster DNS, Gateway API, named ports, egress-only policy, headless + ExternalName | zero-trust namespace (4 policies + trap), TLS Ingress with generated cert + fingerprint, Gateway canary with header match and weights, DNS down three ways |
| Storage | PV/PVC/pod, default StorageClass, re-bind a Released PV, data survives the pod | bind the right local volume by selector, migrate data between claims via a Job |
| Troubleshooting | node NotReady, broken deployment + service, CrashLoopBackOff, scheduler down, CoreDNS misconfig, controller-manager down, kubelet config drift | worker with three faults (+ uncordon check), two control-plane components down, app down for four reasons fixed in place (UID check), cluster facts exactly |
Disruptive tasks (anything that breaks a node or control-plane component) are seeded last and
can block other tasks until fixed — read everything first, as in the real exam. kindnet doesn't
enforce NetworkPolicy and no ingress/gateway controller runs, so those are graded on the objects.
## Adding tasks or a new track
Banks live in `tracks/cka/{questions,bank_extra,bank_hard}.py`; a task is:
```python
{"id": 44, "domain": "NET", "difficulty": "normal", # or "hard"
"params": lambda r: {"ns": ns_pick(r, "svc"), "port": r.choice([8080, 9090])},
"title": "...", "task": "...markdown; %{ns} and %{port} are substituted; `code` copies on click...",
"setup": "bash that seeds the scenario (may use %{ns})",
"checks": [C("what this check proves", 2, "bash; exit 0 = pass"), ...],
"disruptive": False}
```
Scripts get `$K` (pinned to the grading context), `$WS`, `$CP/$W1/$W2/$W3`, `nx NODE 'cmd'`,
and helpers `j <get args> '{jsonpath}'` and `eq a b`. Task text may use `{WS}`, `{CP}`, `{W2}`,
`{W3}`, `{SHELL_CP}` etc. `TRACK["quota"]` / `["quota_hard"]` set how many tasks per domain a
session draws. For CKAD/CKS, fill in `QUESTIONS` in the
existing placeholder track; `TRACK` already carries the domain table and pass mark.

1105
exam Executable file

File diff suppressed because it is too large Load Diff

17
examrc Normal file
View File

@ -0,0 +1,17 @@
# cka-sim shell setup. Sourced automatically by the terminal in ./exam web,
# and safe to source yourself: source ./examrc
[ -f ~/.bashrc ] && . ~/.bashrc
alias k=kubectl
export do='--dry-run=client -o yaml' # k create deploy x --image=nginx $do > x.yaml
export now='--force --grace-period 0' # k delete pod x $now
export KUBE_EDITOR="${KUBE_EDITOR:-vi}"
# completion, including for the alias
source <(kubectl completion bash) 2>/dev/null
complete -o default -F __start_kubectl k 2>/dev/null
# vim defaults that make pasted YAML behave
if [ ! -f ~/.vimrc ]; then
printf 'set tabstop=2\nset expandtab\nset shiftwidth=2\n' > ~/.vimrc
fi

7
examrc.fish Normal file
View File

@ -0,0 +1,7 @@
# cka-sim shell setup for fish: source ./examrc.fish
alias k kubectl
set -gx KUBE_EDITOR (test -n "$KUBE_EDITOR"; and echo $KUBE_EDITOR; or echo vi)
# lists, so they expand to separate arguments
set -g do --dry-run=client -o yaml
set -g now --force --grace-period 0
kubectl completion fish 2>/dev/null | source

8
kind-config.yaml Normal file
View File

@ -0,0 +1,8 @@
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: cka-sim
nodes:
- role: control-plane
- role: worker
- role: worker
- role: worker

17
node-tools.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Install vim/nano/less/jq on every kind node (the real exam has an editor on each node).
# Re-run this after ./setup.sh if the install was skipped or the network was down.
set -euo pipefail
cd "$(dirname "$0")"
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
for n in $(kind get nodes --name cka-sim); do
printf '%s: ' "$n"
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then echo "already has vim"; continue; fi
if "$RT" exec "$n" sh -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq vim nano less jq >/dev/null 2>&1'; then
"$RT" exec "$n" sh -c 'printf "set tabstop=2\nset expandtab\nset shiftwidth=2\n" > /root/.vimrc'
echo ok
else
echo "FAILED (no outbound network?)"
fi
done

86
setup.sh Executable file
View File

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Bootstraps the cka-sim kind cluster (1 control-plane + 3 workers) on podman and preloads images.
# Override with CONTAINER_RUNTIME=docker if you ever want docker instead.
set -euo pipefail
cd "$(dirname "$0")"
RT="${CONTAINER_RUNTIME:-podman}"
need() {
command -v "$1" >/dev/null 2>&1 || { echo "ERROR: '$1' is not installed. $2"; exit 1; }
}
need "$RT" "https://podman.io/docs/installation"
need kind "https://kind.sigs.k8s.io/docs/user/quick-start/#installation"
need kubectl "https://kubernetes.io/docs/tasks/tools/"
need python3 ""
if [ "$RT" = podman ]; then
export KIND_EXPERIMENTAL_PROVIDER=podman
podman info >/dev/null 2>&1 || { echo "ERROR: podman is not working (try: podman info)."; exit 1; }
# kind needs cgroup v2 with controller delegation when podman is rootless.
if [ "$(id -u)" != 0 ] && [ "$(podman info --format '{{.Host.CgroupsVersion}}' 2>/dev/null)" != "v2" ]; then
echo "ERROR: rootless podman needs cgroup v2 for kind. Either enable cgroup v2 or run setup as root (sudo -E ./setup.sh)."
exit 1
fi
# inotify limits are the #1 cause of pods stuck in ContainerCreating on multi-node kind
for k in fs.inotify.max_user_watches:524288 fs.inotify.max_user_instances:512; do
key=${k%%:*}; want=${k##*:}; have=$(sysctl -n "$key" 2>/dev/null || echo 0)
[ "$have" -ge "$want" ] || echo "warn: $key=$have (recommend >= $want: sudo sysctl $key=$want)"
done
else
docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon is not running/reachable."; exit 1; }
fi
if kind get clusters 2>/dev/null | grep -qx cka-sim; then
echo "==> kind cluster 'cka-sim' already exists, reusing it."
else
echo "==> Creating kind cluster 'cka-sim' on $RT (1 control-plane + 3 workers)..."
kind create cluster --config kind-config.yaml ${KIND_NODE_IMAGE:+--image "$KIND_NODE_IMAGE"} --wait 180s
fi
kubectl config use-context kind-cka-sim >/dev/null
echo "==> Waiting for nodes..."
kubectl wait --for=condition=Ready nodes --all --timeout=180s
echo "==> Preloading images into the cluster (non-fatal if a pull fails)..."
for img in nginx:1.25 nginx:1.27 busybox:1.36 redis:7-alpine; do
if "$RT" pull -q "docker.io/library/$img" >/dev/null 2>&1; then
# podman stores the fully-qualified name; kind load accepts either
kind load docker-image --name cka-sim "docker.io/library/$img" >/dev/null 2>&1 \
&& echo " loaded $img" || echo " warn: could not load $img"
else
echo " warn: could not pull $img (tasks will pull at runtime)"
fi
done
echo "==> Making sure the exam workdir exists on the control-plane node..."
"$RT" exec cka-sim-control-plane mkdir -p /opt/cka
# The real exam gives you vim (and less/jq) on every node; the kind node image is minimal, so
# install them once here. Skip with CKA_SKIP_NODE_TOOLS=1.
if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then
echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..."
for n in $(kind get nodes --name cka-sim); do
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then
echo " $n already has vim"
continue
fi
if "$RT" exec "$n" sh -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq vim nano less jq >/dev/null 2>&1'; then
"$RT" exec "$n" sh -c 'printf "set tabstop=2\nset expandtab\nset shiftwidth=2\n" > /root/.vimrc'
echo " $n ok"
else
echo " warn: could not install tools on $n (no outbound network?). Fall back to:"
echo " $RT cp $n:/path/file /tmp/f && \$EDITOR /tmp/f && $RT cp /tmp/f $n:/path/file"
fi
done
fi
# remember the runtime so the CLI uses the matching exec command
mkdir -p .state && echo "$RT" > .state/runtime
chmod +x exam
echo
echo "Cluster ready. Nodes:"
kubectl get nodes -o wide
echo
echo "Next: ./exam start (seeds all 20 tasks and starts the 120-minute clock)"

9
teardown.sh Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Deletes the kind cluster and all local exam state.
set -euo pipefail
cd "$(dirname "$0")"
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
kind delete cluster --name cka-sim || true
rm -rf .state workspace
echo "cka-sim cluster and local state removed."

0
tracks/__init__.py Normal file
View File

646
tracks/cka/bank_extra.py Normal file
View File

@ -0,0 +1,646 @@
"""Extra normal-difficulty CKA tasks (ids 21-43). Parameters (%{...}) are drawn per session."""
from tracks.cka.common import C, NS, ns_pick, name_pick
QUESTIONS = [
# ---------------------------------------------------------------- ARCH ----
{
"id": 21, "domain": "ARCH", "title": "Helm release install and upgrade",
"params": lambda r: {"ns": ns_pick(r, "helm"), "rel": name_pick(r, "web"), "n": r.choice([3, 4, 5])},
"task": """
A Helm chart is at `{WS}/q21/chart` (values: `replicaCount`, `image.*`, `service.type`, `service.port`).
1. Install it as release `%{rel}` into namespace `%{ns}` (create the namespace) with
`replicaCount=%{n}` overridden.
2. Then **upgrade** the same release so the Service type becomes `NodePort`
(value `service.type`). The release must be at revision 2 or higher.
""",
"setup": NS + r"""
mkns %{ns}
rm -rf "$WS/q21"; mkdir -p "$WS/q21/chart/templates"
cat > "$WS/q21/chart/Chart.yaml" <<'EOF'
apiVersion: v2
name: chart
description: A small web chart for the exam simulator
type: application
version: 0.1.0
appVersion: "1.27"
EOF
cat > "$WS/q21/chart/values.yaml" <<'EOF'
replicaCount: 1
image:
repository: nginx
tag: "1.27"
service:
type: ClusterIP
port: 80
EOF
cat > "$WS/q21/chart/templates/_helpers.tpl" <<'EOF'
{{- define "chart.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
EOF
cat > "$WS/q21/chart/templates/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "chart.fullname" . }}
labels: {app: {{ include "chart.fullname" . }}}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {app: {{ include "chart.fullname" . }}}
template:
metadata:
labels: {app: {{ include "chart.fullname" . }}}
spec:
containers:
- name: web
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports: [{containerPort: 80}]
EOF
cat > "$WS/q21/chart/templates/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata:
name: {{ include "chart.fullname" . }}
spec:
type: {{ .Values.service.type }}
selector: {app: {{ include "chart.fullname" . }}}
ports: [{port: {{ .Values.service.port }}, targetPort: 80}]
EOF
command -v helm >/dev/null || echo "warn: helm is not installed on this machine - Q21 cannot be completed or graded without it"
helm -n %{ns} uninstall %{rel} >/dev/null 2>&1 || true
""",
"checks": [
C("release %{rel} deployed in %{ns}", 1, r"""helm -n %{ns} status %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;sys.exit(0 if json.load(sys.stdin)["info"]["status"]=="deployed" else 1)'"""),
C("replicaCount=%{n} applied", 1, r"""eq "$(j -n %{ns} deploy %{rel}-chart '{.spec.replicas}')" %{n}"""),
C("upgraded: revision >= 2 and Service is NodePort", 2, r"""
rev=$(helm -n %{ns} history %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;print(max(r["revision"] for r in json.load(sys.stdin)))' 2>/dev/null)
[ -n "$rev" ] && [ "$rev" -ge 2 ] && eq "$(j -n %{ns} svc %{rel}-chart '{.spec.type}')" NodePort"""),
],
},
{
"id": 33, "domain": "ARCH", "title": "Aggregated ClusterRole",
"params": lambda r: {"cr": name_pick(r, "ops"), "user": r.choice(["dana", "lee", "sam"])},
"task": """
1. Create a ClusterRole `%{cr}` that has **no rules of its own** but aggregates every
ClusterRole labelled `rbac.example.com/aggregate-to-%{cr}=true`.
2. Create two ClusterRoles carrying that label: `%{cr}-pods` (get/list/watch pods) and
`%{cr}-services` (get/list/watch services).
3. Bind `%{cr}` cluster-wide to user `%{user}` with ClusterRoleBinding `%{cr}-binding`.
`kubectl auth can-i list pods --as=%{user} -A` must say yes; deleting pods must say no.
""",
"setup": r"""
for x in %{cr} %{cr}-pods %{cr}-services; do $K delete clusterrole $x --ignore-not-found >/dev/null; done
$K delete clusterrolebinding %{cr}-binding --ignore-not-found >/dev/null
""",
"checks": [
C("%{cr} has an aggregationRule with the right label", 1, r"""j clusterrole %{cr} '{.spec.aggregationRule.clusterRoleSelectors[*].matchLabels}' | grep -q 'aggregate-to-%{cr}":"true'"""),
C("aggregation populated rules from the two labelled roles", 2, r"""j clusterrole %{cr} '{.rules[*].resources}' | grep -q pods && j clusterrole %{cr} '{.rules[*].resources}' | grep -q services"""),
C("%{user} can list pods and services cluster-wide", 1, r"""eq "$($K auth can-i list pods -A --as=%{user} 2>/dev/null)" yes && eq "$($K auth can-i list services -A --as=%{user} 2>/dev/null)" yes"""),
C("%{user} cannot delete pods", 1, r"""eq "$($K auth can-i delete pods -A --as=%{user} 2>/dev/null || true)" no"""),
],
},
{
"id": 34, "domain": "ARCH", "title": "Discover a CRD field with kubectl explain",
"params": lambda r: {"kind": r.choice(["Widget", "Gadget", "Gizmo"]), "field": r.choice(["retries", "attempts", "tries"]), "n": r.randint(2, 7)},
"task": """
A CRD for kind `%{kind}` (group `demo.io`) is installed. Somewhere in its schema is an
integer field whose description is exactly **"Number of retry attempts"**.
1. Using `kubectl explain`, find the field's full path (e.g. `spec.foo.bar`) and write it
on one line to `{WS}/q34/path.txt`.
2. Create a `%{kind}` named `sample` in namespace `default` with that field set to `%{n}`.
""",
"setup": r"""
rm -rf "$WS/q34"; mkdir -p "$WS/q34"
K_LOWER=$(echo %{kind} | tr A-Z a-z)
$K delete crd ${K_LOWER}s.demo.io --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: ${K_LOWER}s.demo.io}
spec:
group: demo.io
scope: Namespaced
names: {kind: %{kind}, plural: ${K_LOWER}s, singular: ${K_LOWER}}
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
mode: {type: string, description: "Operating mode"}
tuning:
type: object
description: "Performance tuning knobs"
properties:
%{field}: {type: integer, description: "Number of retry attempts"}
backoffSeconds: {type: integer, description: "Delay between attempts"}
EOF
""",
"checks": [
C("path.txt has the correct field path", 2, r"""[ "$(tr -d '[:space:]' < "$WS/q34/path.txt" 2>/dev/null)" = "spec.tuning.%{field}" ]"""),
C("%{kind}/sample created with the field = %{n}", 2, r"""K_LOWER=$(echo %{kind} | tr A-Z a-z); eq "$(j -n default ${K_LOWER}s.demo.io sample '{.spec.tuning.%{field}}')" %{n}"""),
],
},
{
"id": 40, "domain": "ARCH", "title": "kubeadm join token",
"params": lambda r: {"ttl": r.choice(["2h", "6h", "24h"])},
"task": """
A new worker is going to be added to the cluster.
On the control-plane node (`{SHELL_CP}`), create a **new** bootstrap token with TTL `%{ttl}`
and save the complete `kubeadm join` command for it (with `--discovery-token-ca-cert-hash`)
to `{WS}/q40/join.sh`.
""",
"setup": r"""
rm -rf "$WS/q40"; mkdir -p "$WS/q40"
for t in $($K -n kube-system get secrets -o jsonpath='{.items[?(@.type=="bootstrap.kubernetes.io/token")].metadata.name}'); do $K -n kube-system delete secret $t >/dev/null; done
""",
"checks": [
C("join.sh contains a kubeadm join with token and CA hash", 2, r"""grep -q 'kubeadm join' "$WS/q40/join.sh" 2>/dev/null && grep -q -- '--token' "$WS/q40/join.sh" && grep -q -- '--discovery-token-ca-cert-hash sha256:' "$WS/q40/join.sh" """),
C("the token in the file exists in the cluster with TTL ~%{ttl}", 3, r"""
tok=$(grep -o -- '--token [a-z0-9]\{6\}\.[a-z0-9]\{16\}' "$WS/q40/join.sh" 2>/dev/null | awk '{print $2}'); [ -n "$tok" ] || exit 1
id=${tok%%.*}; exp=$(j -n kube-system secret bootstrap-token-$id '{.data.expiration}' | base64 -d 2>/dev/null); [ -n "$exp" ] || exit 1
secs=$(( $(date -d "$exp" +%s) - $(date +%s) )); want=$(( $(echo %{ttl} | tr -d h) * 3600 ))
[ $secs -gt $((want - 900)) ] && [ $secs -le $((want + 60)) ]"""),
],
},
{
"id": 41, "domain": "ARCH", "title": "Component versions",
"params": lambda r: {"node": r.choice(["{W1}", "{W2}", "{W3}"])},
"task": """
Write the following to `{WS}/q41/versions.txt`, one per line, in this exact format:
```
apiserver=<version>
etcd=<version>
kubelet=<version>
runtime=<containerRuntimeVersion of node %{node}>
```
Use the values the cluster itself reports (`kubectl`, not what you remember).
""",
"setup": r"""rm -rf "$WS/q41"; mkdir -p "$WS/q41" """,
"checks": [
C("apiserver and kubelet versions correct", 2, r"""
f="$WS/q41/versions.txt"; [ -f "$f" ] || exit 1
api=$($K version -o json 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])')
kub=$(j node %{node} '{.status.nodeInfo.kubeletVersion}')
grep -qx "apiserver=$api" "$f" && grep -qx "kubelet=$kub" "$f" """),
C("etcd and runtime versions correct", 2, r"""
f="$WS/q41/versions.txt"
etcd=$(j -n kube-system pod -l component=etcd '{.items[0].spec.containers[0].image}' | sed 's/.*://')
rt=$(j node %{node} '{.status.nodeInfo.containerRuntimeVersion}')
grep -qx "etcd=$etcd" "$f" && grep -qx "runtime=$rt" "$f" """),
],
},
# ---------------------------------------------------------------- WORK ----
{
"id": 23, "domain": "WORK", "title": "metrics-server repair and HPA",
"params": lambda r: {"ns": ns_pick(r, "scale"), "dep": name_pick(r, "api"), "max": r.choice([5, 6, 8]), "cpu": r.choice([60, 70, 75])},
"task": """
`metrics-server` was installed in `kube-system` but `kubectl top nodes` does not work.
1. Fix metrics-server so `kubectl top nodes` returns data (hint: in this cluster the kubelet
serving certificates are not signed by the cluster CA).
2. Create an HPA `%{dep}` for Deployment `%{dep}` in namespace `%{ns}`:
min 2, max %{max}, target average CPU utilisation %{cpu}%.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create deploy %{dep} --image=nginx:1.25 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} set resources deploy %{dep} --requests=cpu=50m >/dev/null
$K -n %{ns} delete hpa %{dep} --ignore-not-found >/dev/null
if ! $K -n kube-system get deploy metrics-server >/dev/null 2>&1; then
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml -o /tmp/ms.yaml && $K apply -f /tmp/ms.yaml >/dev/null || echo "warn: could not download metrics-server manifest"
fi
# make it broken (kind kubelets use self-signed serving certs)
$K -n kube-system get deploy metrics-server -o json 2>/dev/null | python3 -c '
import sys,json; d=json.load(sys.stdin); c=d["spec"]["template"]["spec"]["containers"][0]
c["args"]=[a for a in c["args"] if a!="--kubelet-insecure-tls"]; print(json.dumps(d))' | $K apply -f - >/dev/null 2>&1 || true
""",
"checks": [
C("kubectl top nodes works", 2, r"""for i in $(seq 1 12); do $K top nodes >/dev/null 2>&1 && exit 0; sleep 5; done; exit 1"""),
C("HPA %{dep}: min 2, max %{max}", 1, r"""eq "$(j -n %{ns} hpa %{dep} '{.spec.minReplicas}')" 2 && eq "$(j -n %{ns} hpa %{dep} '{.spec.maxReplicas}')" %{max}"""),
C("HPA targets CPU %{cpu}% on the deployment", 1, r"""eq "$(j -n %{ns} hpa %{dep} '{.spec.scaleTargetRef.name}')" %{dep} && j -n %{ns} hpa %{dep} '{.spec.metrics[*].resource}' | grep -q '"averageUtilization":%{cpu}'"""),
],
},
{
"id": 24, "domain": "WORK", "title": "PriorityClass",
"params": lambda r: {"ns": ns_pick(r, "prio"), "pc": name_pick(r, "critical"), "val": r.choice([100000, 500000, 900000])},
"task": """
1. Create a PriorityClass `%{pc}` with value %{val}, not the global default, description "%{pc} workloads".
2. In namespace `%{ns}`, Deployment `payments` already exists. Make its pods use `%{pc}` and
ensure a rollout completes (pods must show priority %{val}).
""",
"setup": NS + r"""
mkns %{ns}
$K delete priorityclass %{pc} --ignore-not-found >/dev/null
$K -n %{ns} create deploy payments --image=nginx:1.25 --replicas=2 --dry-run=client -o yaml | $K apply -f - >/dev/null
""",
"checks": [
C("PriorityClass %{pc} value %{val}, not default", 2, r"""eq "$(j priorityclass %{pc} '{.value}')" %{val} && [ "$(j priorityclass %{pc} '{.globalDefault}')" != true ]"""),
C("payments pods running with priority %{val}", 2, r"""eq "$(j -n %{ns} deploy payments '{.status.availableReplicas}')" 2 && for p in $(j -n %{ns} pod -l app=payments '{.items[*].spec.priority}'); do eq "$p" %{val} || exit 1; done"""),
],
},
{
"id": 25, "domain": "WORK", "breaks": "w1-taint", "title": "Taints and tolerations",
"params": lambda r: {"ns": ns_pick(r, "sched"), "key": r.choice(["dedicated", "workload", "team"]), "val": r.choice(["gpu", "batch", "finance"]), "dep": name_pick(r, "worker")},
"task": """
1. Taint node `{W1}` with `%{key}=%{val}:NoSchedule`.
2. Create Deployment `%{dep}` in namespace `%{ns}` (image `busybox:1.36`, command `sleep 3600`,
2 replicas) that tolerates that taint **and** is pinned to `{W1}` with a nodeSelector on
`kubernetes.io/hostname`.
Both pods must be Running on `{W1}`. Do not remove the taint.
""",
"setup": NS + r"""
mkns %{ns}
$K taint node "$W1" %{key}- >/dev/null 2>&1 || true
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
""",
"checks": [
C("{W1} carries %{key}=%{val}:NoSchedule", 1, r"""j node "$W1" '{.spec.taints}' | grep -q '"key":"%{key}"' && j node "$W1" '{.spec.taints}' | grep -q '"value":"%{val}"' """),
C("deployment tolerates the taint", 1, r"""j -n %{ns} deploy %{dep} '{.spec.template.spec.tolerations}' | grep -q '"key":"%{key}"' """),
C("both pods Running on {W1}", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 2 && for n in $(j -n %{ns} pod -l app=%{dep} '{.items[*].spec.nodeName}'); do eq "$n" "$W1" || exit 1; done"""),
],
},
{
"id": 26, "domain": "WORK", "title": "DaemonSet on every node",
"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`)
that runs on **every** node, including the control-plane.
Requests: cpu 10m, memory 16Mi.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete ds %{ds} --ignore-not-found >/dev/null""",
"checks": [
C("DaemonSet exists with requests set", 1, r"""eq "$(j -n %{ns} ds %{ds} '{.spec.template.spec.containers[0].resources.requests.cpu}')" 10m"""),
C("tolerates the control-plane taint", 1, r"""j -n %{ns} ds %{ds} '{.spec.template.spec.tolerations[*].key}' | grep -q control-plane"""),
C("one ready pod per node (including control-plane)", 2, r"""n=$($K get nodes --no-headers 2>/dev/null | wc -l); eq "$(j -n %{ns} ds %{ds} '{.status.numberReady}')" "$n" && eq "$(j -n %{ns} ds %{ds} '{.status.desiredNumberScheduled}')" "$n" """),
],
},
{
"id": 27, "domain": "WORK", "title": "CronJob with a manual run",
"params": lambda r: {"ns": ns_pick(r, "jobs"), "cj": name_pick(r, "report"), "every": r.choice([5, 10, 15])},
"task": """
1. Create CronJob `%{cj}` in `%{ns}`: runs every %{every} minutes, image `busybox:1.36`,
command `date`, `concurrencyPolicy: Forbid`, keep 3 successful and 1 failed job,
each run limited to 30 seconds (`activeDeadlineSeconds`), restartPolicy Never.
2. Trigger it manually once as a Job named `%{cj}-manual` and let it complete.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete cj %{cj} --ignore-not-found >/dev/null; $K -n %{ns} delete job %{cj}-manual --ignore-not-found >/dev/null""",
"checks": [
C("schedule every %{every} minutes, Forbid, history limits", 2, r"""c="$K -n %{ns} get cj %{cj}"; eq "$($c -o jsonpath='{.spec.schedule}' 2>/dev/null)" "*/%{every} * * * *" && eq "$($c -o jsonpath='{.spec.concurrencyPolicy}')" Forbid && eq "$($c -o jsonpath='{.spec.successfulJobsHistoryLimit}')" 3 && eq "$($c -o jsonpath='{.spec.failedJobsHistoryLimit}')" 1"""),
C("activeDeadlineSeconds 30, restartPolicy Never", 1, r"""eq "$(j -n %{ns} cj %{cj} '{.spec.jobTemplate.spec.activeDeadlineSeconds}')" 30 && eq "$(j -n %{ns} cj %{cj} '{.spec.jobTemplate.spec.template.spec.restartPolicy}')" Never"""),
C("manual job %{cj}-manual completed", 1, r"""for i in $(seq 1 10); do [ "$(j -n %{ns} job %{cj}-manual '{.status.succeeded}')" = 1 ] && exit 0; sleep 3; done; exit 1"""),
],
},
{
"id": 28, "domain": "WORK", "title": "Init container prepares content",
"params": lambda r: {"ns": ns_pick(r, "web"), "pod": name_pick(r, "site"), "text": r.choice(["hello from init", "prepared by init", "init was here"])},
"task": """
Create pod `%{pod}` in namespace `%{ns}`:
- init container `setup` (busybox:1.36) writes the text `%{text}` into `/work/index.html`,
- main container `web` (nginx:1.25) serves that file from `/usr/share/nginx/html`,
- both share an `emptyDir` volume named `content`.
`curl` from inside the cluster to the pod on port 80 must return the text.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null""",
"checks": [
C("pod Running, init container named setup completed", 1, r"""eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && eq "$(j -n %{ns} pod %{pod} '{.spec.initContainers[0].name}')" setup"""),
C("emptyDir 'content' shared by both containers", 1, r"""[ -n "$(j -n %{ns} pod %{pod} '{.spec.volumes[?(@.name=="content")].emptyDir}')" ] && j -n %{ns} pod %{pod} '{.spec.containers[0].volumeMounts[*].name}' | grep -qw content"""),
C("nginx serves the init-written content", 2, r"""$K -n %{ns} exec %{pod} -c web -- cat /usr/share/nginx/html/index.html 2>/dev/null | grep -q '%{text}'"""),
],
},
{
"id": 29, "domain": "WORK", "title": "Immutable ConfigMap rotation",
"params": lambda r: {"ns": ns_pick(r, "cfg"), "dep": name_pick(r, "app"), "cm": name_pick(r, "settings")},
"task": """
Deployment `%{dep}` in `%{ns}` mounts ConfigMap `%{cm}` at `/etc/cfg`. The ConfigMap is
**immutable** and its key `mode` is `old`.
The application must now read `mode=new`. Without deleting `%{cm}` and without editing it
(it cannot be edited), make the running pods see `new` at `/etc/cfg/mode`.
Hint: version the ConfigMap.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
$K -n %{ns} delete cm -l q=29 --ignore-not-found >/dev/null; $K -n %{ns} delete cm %{cm} --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: ConfigMap
metadata: {name: %{cm}, namespace: %{ns}, labels: {q: "29"}}
immutable: true
data: {mode: old}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: app
image: busybox:1.36
command: [sleep, "3600"]
volumeMounts: [{name: cfg, mountPath: /etc/cfg}]
volumes: [{name: cfg, configMap: {name: %{cm}}}]
EOF
""",
"checks": [
C("original ConfigMap %{cm} still exists and is immutable", 1, r"""eq "$(j -n %{ns} cm %{cm} '{.immutable}')" true && eq "$(j -n %{ns} cm %{cm} '{.data.mode}')" old"""),
C("deployment now references a different ConfigMap", 1, r"""n=$(j -n %{ns} deploy %{dep} '{.spec.template.spec.volumes[0].configMap.name}'); [ -n "$n" ] && [ "$n" != %{cm} ]"""),
C("all pods available and see mode=new", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 2 && for p in $(j -n %{ns} pod -l app=%{dep} '{.items[*].metadata.name}'); do eq "$($K -n %{ns} exec $p -- cat /etc/cfg/mode 2>/dev/null)" new || exit 1; done"""),
],
},
{
"id": 31, "domain": "WORK", "title": "Parallel Job",
"params": lambda r: {"ns": ns_pick(r, "batch"), "job": name_pick(r, "crunch"), "c": r.choice([6, 8, 9]), "p": r.choice([2, 3])},
"task": """
Create Job `%{job}` in `%{ns}`: image `busybox:1.36`, command `sh -c "echo done; sleep 2"`,
%{c} completions, %{p} in parallel, `backoffLimit: 2`, restartPolicy Never. Let it finish.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete job %{job} --ignore-not-found >/dev/null""",
"checks": [
C("completions %{c}, parallelism %{p}, backoffLimit 2", 2, r"""eq "$(j -n %{ns} job %{job} '{.spec.completions}')" %{c} && eq "$(j -n %{ns} job %{job} '{.spec.parallelism}')" %{p} && eq "$(j -n %{ns} job %{job} '{.spec.backoffLimit}')" 2"""),
C("job succeeded %{c} times", 2, r"""for i in $(seq 1 20); do [ "$(j -n %{ns} job %{job} '{.status.succeeded}')" = %{c} ] && exit 0; sleep 3; done; exit 1"""),
],
},
{
"id": 32, "domain": "WORK", "title": "ResourceQuota and LimitRange",
"params": lambda r: {"ns": ns_pick(r, "team"), "pods": r.choice([4, 5, 6]), "dcpu": r.choice(["100m", "150m", "200m"])},
"task": """
In namespace `%{ns}`:
1. ResourceQuota `%{ns}-quota`: at most %{pods} pods, total CPU requests 1, total memory limits 2Gi.
2. LimitRange `%{ns}-defaults`: for containers, default request cpu %{dcpu} / memory 64Mi and
default limit cpu 500m / memory 256Mi.
3. Create Deployment `worker` (nginx:1.25, 2 replicas) **without** specifying resources.
Its pods must be Running with the defaults applied.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete quota,limitrange --all >/dev/null 2>&1; $K -n %{ns} delete deploy worker --ignore-not-found >/dev/null""",
"checks": [
C("ResourceQuota limits pods/%{pods}, requests.cpu 1, limits.memory 2Gi", 2, r"""q="$K -n %{ns} get quota %{ns}-quota"; eq "$($q -o jsonpath='{.spec.hard.pods}' 2>/dev/null)" %{pods} && eq "$($q -o jsonpath='{.spec.hard.requests\.cpu}')" 1 && eq "$($q -o jsonpath='{.spec.hard.limits\.memory}')" 2Gi"""),
C("LimitRange defaults set", 1, r"""l="$K -n %{ns} get limitrange %{ns}-defaults"; eq "$($l -o jsonpath='{.spec.limits[0].defaultRequest.cpu}' 2>/dev/null)" %{dcpu} && eq "$($l -o jsonpath='{.spec.limits[0].default.memory}')" 256Mi"""),
C("worker pods Running with defaulted requests", 1, r"""eq "$(j -n %{ns} deploy worker '{.status.availableReplicas}')" 2 && eq "$(j -n %{ns} pod -l app=worker '{.items[0].spec.containers[0].resources.requests.cpu}')" %{dcpu}"""),
],
},
# ----------------------------------------------------------------- NET ----
{
"id": 22, "domain": "NET", "title": "Gateway API HTTPRoute",
"params": lambda r: {"ns": ns_pick(r, "gw"), "gc": name_pick(r, "lab-gc"), "gw": name_pick(r, "edge"), "host": r.choice(["shop", "api", "app"])},
"task": """
1. Install the Gateway API **standard** channel CRDs (v1.2.1 or newer) into the cluster.
2. Create a GatewayClass `%{gc}` (controllerName `example.com/gateway`).
3. In namespace `%{ns}` create Gateway `%{gw}` using that class, one HTTP listener on port 80
for hostname `%{host}.example.com`.
4. Create HTTPRoute `%{host}-route` attached to `%{gw}` that sends `/api` (PathPrefix) to
Service `api` port 8080 and everything else to Service `web` port 80.
(No controller runs here; you are graded on the objects.)
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} create deploy api --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} expose deploy api --port=8080 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K delete gatewayclass %{gc} --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete gateway %{gw} --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete httproute %{host}-route --ignore-not-found >/dev/null 2>&1
""",
"checks": [
C("Gateway API CRDs installed (gateways, httproutes)", 1, r"""$K get crd gateways.gateway.networking.k8s.io httproutes.gateway.networking.k8s.io >/dev/null 2>&1"""),
C("GatewayClass %{gc} with the controllerName", 1, r"""eq "$(j gatewayclass %{gc} '{.spec.controllerName}')" example.com/gateway"""),
C("Gateway %{gw}: class, HTTP listener :80 for %{host}.example.com", 2, r"""g="$K -n %{ns} get gateway %{gw}"; eq "$($g -o jsonpath='{.spec.gatewayClassName}' 2>/dev/null)" %{gc} && eq "$($g -o jsonpath='{.spec.listeners[0].port}')" 80 && eq "$($g -o jsonpath='{.spec.listeners[0].hostname}')" %{host}.example.com"""),
C("HTTPRoute: parentRef %{gw}, /api -> api:8080, default -> web:80", 2, r"""
h="$K -n %{ns} get httproute %{host}-route"; $h >/dev/null 2>&1 || exit 1
eq "$($h -o jsonpath='{.spec.parentRefs[0].name}')" %{gw} || exit 1
$h -o json | python3 -c '
import sys,json; r=json.load(sys.stdin)["spec"]["rules"]
api=[x for x in r if any(m.get("path",{}).get("value")=="/api" for m in x.get("matches",[]))]
web=[x for x in r if x is not api and x.get("backendRefs",[{}])[0].get("name")=="web"]
ok=api and api[0]["backendRefs"][0]["name"]=="api" and api[0]["backendRefs"][0]["port"]==8080 and web and web[0]["backendRefs"][0]["port"]==80
sys.exit(0 if ok else 1)'"""),
],
},
{
"id": 30, "domain": "NET", "title": "Named ports",
"params": lambda r: {"ns": ns_pick(r, "ports"), "dep": name_pick(r, "catalog"), "pname": r.choice(["http-web", "app-port", "svc-http"])},
"task": """
1. In `%{ns}`, Deployment `%{dep}` (nginx:1.25, 1 replica) must declare its container port 80
with the **name** `%{pname}`.
2. Create Service `%{dep}` (ClusterIP, port 8080) whose `targetPort` references the port
**by name**, not by number.
3. Add a readiness probe on the container that also references the port by name (HTTP GET /).
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete deploy,svc %{dep} --ignore-not-found >/dev/null""",
"checks": [
C("container port 80 named %{pname}", 1, r"""eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].ports[?(@.containerPort==80)].name}')" %{pname}"""),
C("Service port 8080 -> targetPort %{pname} (by name)", 2, r"""eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].port}')" 8080 && eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].targetPort}')" %{pname}"""),
C("readiness probe uses the named port; endpoints ready", 1, r"""eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].readinessProbe.httpGet.port}')" %{pname} && [ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{dep} '{.items[*].endpoints[?(@.conditions.ready==true)].addresses[*]}')" ]"""),
],
},
{
"id": 42, "domain": "NET", "title": "Egress-only NetworkPolicy",
"params": lambda r: {"ns": ns_pick(r, "egress"), "app": name_pick(r, "client"), "cidr": r.choice(["10.96.0.0/12", "172.16.0.0/16", "192.168.100.0/24"])},
"task": """
Namespace `%{ns}` has a Deployment `%{app}` (label `app=%{app}`).
Create NetworkPolicy `%{app}-egress` that applies only to those pods and allows **egress** to:
- DNS (UDP and TCP 53) to pods in `kube-system` labelled `k8s-app=kube-dns`,
- TCP 443 to the CIDR `%{cidr}`,
and denies all other egress. Do not restrict ingress.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} create deploy %{app} --image=busybox:1.36 --dry-run=client -o yaml -- sleep 3600 | $K apply -f - >/dev/null; $K -n %{ns} delete netpol %{app}-egress --ignore-not-found >/dev/null""",
"checks": [
C("applies to app=%{app}, Egress only", 1, r"""n="$K -n %{ns} get netpol %{app}-egress"; eq "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" %{app} && eq "$($n -o jsonpath='{.spec.policyTypes}')" '["Egress"]'"""),
C("DNS rule: kube-system namespace + k8s-app=kube-dns, ports 53 UDP and TCP", 2, r"""
$K -n %{ns} get netpol %{app}-egress -o json 2>/dev/null | python3 -c '
import sys,json; e=json.load(sys.stdin)["spec"].get("egress",[])
dns=[r for r in e if any((p.get("port")==53 or p.get("port")=="53") for p in r.get("ports",[]))]
ok=False
for r in dns:
protos={p.get("protocol","TCP") for p in r["ports"] if str(p.get("port"))=="53"}
to=r.get("to",[])
nsok=any("kube-system" in json.dumps(t.get("namespaceSelector",{})) for t in to)
podok=any(t.get("podSelector",{}).get("matchLabels",{}).get("k8s-app")=="kube-dns" for t in to)
if protos>={"UDP","TCP"} and nsok and podok: ok=True
sys.exit(0 if ok else 1)'"""),
C("TCP 443 to %{cidr} only", 1, r"""
$K -n %{ns} get netpol %{app}-egress -o json 2>/dev/null | python3 -c '
import sys,json; e=json.load(sys.stdin)["spec"].get("egress",[])
ok=any(any(t.get("ipBlock",{}).get("cidr")=="%{cidr}" for t in r.get("to",[])) and any(str(p.get("port"))=="443" for p in r.get("ports",[])) for r in e)
sys.exit(0 if ok else 1)'"""),
],
},
{
"id": 43, "domain": "NET", "title": "Headless and ExternalName services",
"params": lambda r: {"ns": ns_pick(r, "svc2"), "sts": name_pick(r, "db"), "ext": r.choice(["api.example.com", "db.corp.example.org", "cache.internal.example.net"])},
"task": """
1. In `%{ns}`, StatefulSet `%{sts}` (2 replicas) exists but has no Service. Create the **headless**
Service `%{sts}` it needs (port 5432) so that `%{sts}-0.%{sts}.%{ns}.svc.cluster.local` resolves.
2. Create Service `legacy` of type ExternalName pointing at `%{ext}`.
3. From a pod in the namespace, resolve `%{sts}-0.%{sts}` and write the full nslookup output
to `{WS}/q43/pod0.txt`.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q43"; mkdir -p "$WS/q43"
$K -n %{ns} delete svc %{sts} legacy --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: StatefulSet
metadata: {name: %{sts}, namespace: %{ns}}
spec:
serviceName: %{sts}
replicas: 2
selector: {matchLabels: {app: %{sts}}}
template:
metadata: {labels: {app: %{sts}}}
spec:
containers: [{name: db, image: busybox:1.36, command: [sleep, "3600"], ports: [{containerPort: 5432}]}]
EOF
""",
"checks": [
C("headless Service %{sts} (clusterIP None) selecting app=%{sts}, port 5432", 2, r"""eq "$(j -n %{ns} svc %{sts} '{.spec.clusterIP}')" None && eq "$(j -n %{ns} svc %{sts} '{.spec.selector.app}')" %{sts} && eq "$(j -n %{ns} svc %{sts} '{.spec.ports[0].port}')" 5432"""),
C("ExternalName Service legacy -> %{ext}", 1, r"""eq "$(j -n %{ns} svc legacy '{.spec.type}')" ExternalName && eq "$(j -n %{ns} svc legacy '{.spec.externalName}')" %{ext}"""),
C("pod0.txt shows %{sts}-0 resolving to its pod IP", 1, r"""ip=$(j -n %{ns} pod %{sts}-0 '{.status.podIP}'); [ -n "$ip" ] && grep -q "$ip" "$WS/q43/pod0.txt" 2>/dev/null"""),
],
},
# ---------------------------------------------------------------- STOR ----
{
"id": 35, "domain": "STOR", "title": "Re-bind a Released PersistentVolume",
"params": lambda r: {"ns": ns_pick(r, "store"), "pv": name_pick(r, "pv-archive"), "pvc": name_pick(r, "archive-claim")},
"task": """
PersistentVolume `%{pv}` (1Gi, hostPath, class `manual`) is in status **Released**: the claim that used
it was deleted, but its data must be kept and reused.
Make `%{pv}` bindable again without deleting it, then create PVC `%{pvc}` in `%{ns}`
(class `manual`, 500Mi, RWO) so it binds to **exactly** that PV. Do not create any other PV.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pvc %{pvc} --ignore-not-found >/dev/null; $K delete pv %{pv} --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolume
metadata: {name: %{pv}}
spec:
capacity: {storage: 1Gi}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: {path: /mnt/%{pv}}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: old-claim, namespace: %{ns}}
spec: {accessModes: [ReadWriteOnce], storageClassName: manual, resources: {requests: {storage: 500Mi}}}
EOF
for i in $(seq 1 20); do [ "$(j -n %{ns} pvc old-claim '{.status.phase}')" = Bound ] && break; sleep 1; done
$K -n %{ns} delete pvc old-claim >/dev/null
""",
"checks": [
C("PV %{pv} still exists and was not recreated", 2, r"""[ -n "$(j pv %{pv} '{.metadata.uid}')" ] && eq "$(j pv %{pv} '{.spec.persistentVolumeReclaimPolicy}')" Retain"""),
C("PVC %{pvc} Bound to %{pv}", 3, r"""eq "$(j -n %{ns} pvc %{pvc} '{.status.phase}')" Bound && eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" %{pv}"""),
C("no extra PVs were created", 1, r"""[ "$($K get pv --no-headers 2>/dev/null | grep -c manual)" -le 2 ]"""),
],
},
{
"id": 36, "domain": "STOR", "title": "Data survives the pod",
"params": lambda r: {"ns": ns_pick(r, "persist"), "pvc": name_pick(r, "notes"), "text": r.choice(["order-9981", "ticket-4471", "batch-2026"])},
"task": """
1. In `%{ns}` create PVC `%{pvc}` (200Mi, RWO) using the cluster's default StorageClass.
2. Run pod `writer` (busybox:1.36) that mounts it at `/data` and writes the text `%{text}`
into `/data/marker.txt`, then exits (restartPolicy Never).
3. After `writer` has completed, run pod `reader` (busybox:1.36, `sleep 3600`) mounting the
same claim at `/data`. `cat /data/marker.txt` inside `reader` must show the text.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod writer reader --ignore-not-found --wait=false >/dev/null; $K -n %{ns} delete pvc %{pvc} --ignore-not-found >/dev/null""",
"checks": [
C("PVC %{pvc} Bound with the default StorageClass", 1, r"""eq "$(j -n %{ns} pvc %{pvc} '{.status.phase}')" Bound && [ -n "$(j -n %{ns} pvc %{pvc} '{.spec.storageClassName}')" ]"""),
C("writer completed (Succeeded)", 1, r"""eq "$(j -n %{ns} pod writer '{.status.phase}')" Succeeded"""),
C("reader sees the marker written by writer", 2, r"""eq "$($K -n %{ns} exec reader -- cat /data/marker.txt 2>/dev/null | tr -d '[:space:]')" %{text}"""),
],
},
# -------------------------------------------------------------- TSHOOT ----
{
"id": 37, "domain": "TSHOOT", "breaks": "coredns", "title": "Cluster DNS is broken", "disruptive": True,
"params": lambda r: {"bad": r.choice(["forward . 10.255.255.1", "forward . 192.0.2.53"])},
"task": """
Pods across the cluster can no longer resolve names (in-cluster or external).
Find the cause in CoreDNS and fix it. `nslookup kubernetes.default` from any pod must work
again, and CoreDNS must be healthy.
""",
"setup": r"""
$K -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' > /tmp/Corefile.orig
sed -i 's|forward \. /etc/resolv.conf|%{bad}|' /tmp/Corefile.orig
$K -n kube-system create cm coredns --from-file=Corefile=/tmp/Corefile.orig --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n kube-system rollout restart deploy coredns >/dev/null
""",
"checks": [
C("CoreDNS Corefile forwards to the node resolver again", 2, r"""j -n kube-system cm coredns '{.data.Corefile}' | grep -q 'forward \. /etc/resolv.conf'"""),
C("CoreDNS pods available", 1, r"""[ "$(j -n kube-system deploy coredns '{.status.availableReplicas}')" -ge 1 ]"""),
C("in-cluster and external names resolve from a pod", 3, r"""
$K -n default delete pod dns-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default run dns-probe --image=busybox:1.36 --restart=Never -- sh -c 'for i in 1 2 3 4 5 6; do nslookup kubernetes.default >/dev/null 2>&1 && nslookup kubernetes.io >/dev/null 2>&1 && echo OK && exit 0; sleep 5; done; exit 1' >/dev/null 2>&1
for i in $(seq 1 20); do ph=$(j -n default pod dns-probe '{.status.phase}'); [ "$ph" = Succeeded ] && { $K -n default delete pod dns-probe --wait=false >/dev/null; exit 0; }; [ "$ph" = Failed ] && break; sleep 3; done
$K -n default delete pod dns-probe --wait=false >/dev/null; exit 1"""),
],
},
{
"id": 38, "domain": "TSHOOT", "breaks": "controller-manager", "title": "Deployments never create pods", "disruptive": True,
"task": """
Creating a Deployment results in **no** ReplicaSet or pods, and nothing is reported in
`kubectl describe deploy`. Existing pods still run.
Find the failing control-plane component, repair it on the control-plane node
(`{SHELL_CP}`) and make sure new Deployments get their pods.
""",
"setup": r"""
nx "$CP" '
f=/etc/kubernetes/manifests/kube-controller-manager.yaml
grep -q "^ *- kube-controller-manager$" $f && sed -i "s|^\( *\)- kube-controller-manager$|\1- kube-controller-manager\n\1- --leader-elect-lease-duration=1s|" $f
'
""",
"checks": [
C("kube-controller-manager manifest repaired and pod Running", 3, r"""! nx "$CP" 'grep -q "leader-elect-lease-duration=1s" /etc/kubernetes/manifests/kube-controller-manager.yaml' && [ "$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].status.phase}')" = Running ]"""),
C("a new Deployment gets a ReplicaSet and running pods", 4, r"""
$K -n default delete deploy cm-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default create deploy cm-probe --image=busybox:1.36 -- sleep 300 >/dev/null 2>&1
ok=1; for i in $(seq 1 24); do [ "$(j -n default deploy cm-probe '{.status.availableReplicas}')" = 1 ] && { ok=0; break; }; sleep 5; done
$K -n default delete deploy cm-probe --wait=false >/dev/null 2>&1; exit $ok"""),
],
},
{
"id": 39, "domain": "TSHOOT", "breaks": "w1-kubelet", "title": "kubelet misconfiguration", "disruptive": True,
"task": """
Node `{W1}` is `NotReady`. Someone "tuned" the kubelet on it last night.
Bring the node back to `Ready`; the kubelet must be running and enabled.
Reach the node with `{SHELL_W1}`. `journalctl -u kubelet` is your friend.
""",
"setup": r"""
nx "$W1" '
cp -n /var/lib/kubelet/config.yaml /var/lib/kubelet/config.yaml.orig 2>/dev/null
sed -i "s|clientCAFile: /etc/kubernetes/pki/ca.crt|clientCAFile: /etc/kubernetes/pki/ca-bundle.crt|" /var/lib/kubelet/config.yaml
systemctl restart kubelet
'
""",
"checks": [
C("kubelet config points at a CA file that exists", 2, r"""f=$(nx "$W1" 'grep clientCAFile /var/lib/kubelet/config.yaml' | awk '{print $2}'); [ -n "$f" ] && nx "$W1" "test -f $f" """),
C("node Ready", 4, r"""for i in $(seq 1 12); do [ "$(j node "$W1" '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && exit 0; sleep 5; done; exit 1"""),
C("kubelet active and enabled", 1, r"""eq "$(nx "$W1" 'systemctl is-active kubelet')" active && eq "$(nx "$W1" 'systemctl is-enabled kubelet')" enabled"""),
],
},
]

660
tracks/cka/bank_hard.py Normal file
View File

@ -0,0 +1,660 @@
"""Hard-difficulty CKA tasks (ids 101-122): multi-part, no hints, traps, exact output,
compound failures, collateral-damage checks, exam-style contexts. Modelled on the kind of
difficulty people report from killer.sh, not on its content."""
from tracks.cka.common import C, NS, ns_pick, name_pick
CTX = "Use context: `kubectl config use-context %{ctx}`\n\n"
def ctx_pick(r):
return r.choice(["k8s-c1-H", "k8s-c2-AC", "k8s-c3-CCC"])
QUESTIONS = [
# ================================================================ ARCH ====
{
"id": 101, "domain": "ARCH", "difficulty": "hard", "title": "Exact RBAC with an existing over-grant",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "ci"), "sa": name_pick(r, "ci-bot")},
"task": CTX + """
ServiceAccount `%{sa}` in namespace `%{ns}` is used by a CI system. Its permissions must be
**exactly**:
- in `%{ns}`: `get`, `list`, `watch`, `patch` on `deployments` and `replicasets` (apps),
and `create` on `pods/exec`,
- cluster-wide: `list` on `nodes`.
Nothing more. Audit what it can do now and correct it. Use Role `%{sa}-role`, RoleBinding
`%{sa}-rb`, ClusterRole `%{sa}-nodes`, ClusterRoleBinding `%{sa}-crb`.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create sa %{sa} --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} delete role,rolebinding --all >/dev/null 2>&1
$K delete clusterrole %{sa}-nodes --ignore-not-found >/dev/null; $K delete clusterrolebinding %{sa}-crb %{sa}-legacy-admin --ignore-not-found >/dev/null
# trap: someone bound cluster-admin to this SA "temporarily"
$K create clusterrolebinding %{sa}-legacy-admin --clusterrole=cluster-admin --serviceaccount=%{ns}:%{sa} >/dev/null
""",
"checks": [
C("can get/list/watch/patch deployments and replicasets in %{ns}", 2, r"""for res in deployments.apps replicasets.apps; do for v in get list watch patch; do eq "$($K auth can-i $v $res -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes || exit 1; done; done"""),
C("can create pods/exec in %{ns}; can list nodes", 2, r"""eq "$($K auth can-i create pods/exec -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes && eq "$($K auth can-i list nodes --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes"""),
C("no over-grant: cannot delete deployments, cannot get secrets, cannot get nodes", 3, r"""for t in "delete deployments -n %{ns}" "get secrets -n %{ns}" "get nodes" "create pods -n default"; do eq "$($K auth can-i $t --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null || true)" no || exit 1; done"""),
C("the cluster-admin binding is gone and named objects exist", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 && $K -n %{ns} get role %{sa}-role rolebinding %{sa}-rb >/dev/null 2>&1 && $K get clusterrole %{sa}-nodes clusterrolebinding %{sa}-crb >/dev/null 2>&1"""),
],
},
{
"id": 102, "domain": "ARCH", "breaks": "etcd", "difficulty": "hard", "title": "etcd restore",
"params": lambda r: {"ctx": "k8s-c1-H", "cm": name_pick(r, "restore-proof")},
"task": CTX + """
An etcd snapshot taken earlier is at `/opt/cka/backup.db` on the control-plane node.
A ConfigMap `%{cm}` in namespace `default` existed when the snapshot was taken and has since
been deleted.
**Restore the cluster's etcd from that snapshot** so the ConfigMap exists again. The cluster
must be healthy afterwards (API server up, all kube-system pods Running).
Do not recreate the ConfigMap by hand — its data must come from the snapshot.
""",
"setup": r"""
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/backup.db'
$K -n default delete cm %{cm} --ignore-not-found >/dev/null
$K -n default create cm %{cm} --from-literal=proof=$(date +%s)-%{cm} >/dev/null
POD=$($K -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].metadata.name}')
$K -n kube-system exec "$POD" -- sh -c 'ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key snapshot save /var/lib/etcd/q102-backup.db' >/dev/null 2>&1
nx "$CP" 'mv /var/lib/etcd/q102-backup.db /opt/cka/backup.db'
for bin in etcdctl etcdutl; do $K -n kube-system exec "$POD" -- cat /usr/local/bin/$bin > /tmp/cka-$bin 2>/dev/null && [ -s /tmp/cka-$bin ] && nx "$CP" "cat > /usr/local/bin/$bin && chmod +x /usr/local/bin/$bin" < /tmp/cka-$bin; rm -f /tmp/cka-$bin; done
sleep 2; $K -n default delete cm %{cm} >/dev/null
""",
"checks": [
C("ConfigMap %{cm} exists again with its original data", 6, r"""j -n default cm %{cm} '{.data.proof}' | grep -q -- '-%{cm}$'"""),
C("etcd uses a restored data directory (manifest changed)", 2, r"""! nx "$CP" 'grep -q "path: /var/lib/etcd$" /etc/kubernetes/manifests/etcd.yaml' """),
C("control plane healthy: etcd, apiserver, scheduler, controller-manager Running", 2, r"""for c in etcd kube-apiserver kube-scheduler kube-controller-manager; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || exit 1; done"""),
],
},
{
"id": 103, "domain": "ARCH", "breaks": "w3-kubelet", "difficulty": "hard", "title": "Static pod on a relocated manifest path",
"params": lambda r: {"ctx": "k8s-c1-H", "ns": ns_pick(r, "edge"), "pod": name_pick(r, "edge-proxy")},
"task": CTX + """
Exactly one worker node is labelled `tier=edge`. On that node create a **static pod**:
- name `%{pod}`, namespace `%{ns}` (create the namespace),
- image `nginx:1.27`, requests cpu 50m / memory 32Mi, label `role=edge`.
The mirror pod must be Running. Note that the kubelet on that node may not be configured the
way you expect.
""",
"setup": NS + r"""
mkns %{ns}
for n in "$W1" "$W2" "$W3"; do $K label node $n tier- >/dev/null 2>&1 || true; done
$K label node "$W3" tier=edge --overwrite >/dev/null
nx "$W3" '
rm -f /etc/kubernetes/manifests/%{pod}*.yaml /etc/kubernetes/static/%{pod}*.yaml 2>/dev/null; mkdir -p /etc/kubernetes/static
sed -i "s|^staticPodPath: .*|staticPodPath: /etc/kubernetes/static|" /var/lib/kubelet/config.yaml
systemctl restart kubelet'
""",
"checks": [
C("mirror pod %{pod}-<edge node> Running in %{ns}", 4, r"""eq "$(j -n %{ns} pod "%{pod}-$W3" '{.status.phase}')" Running"""),
C("image, requests and label correct", 2, r"""p="$K -n %{ns} get pod %{pod}-$W3"; eq "$($p -o jsonpath='{.spec.containers[0].image}' 2>/dev/null)" nginx:1.27 && eq "$($p -o jsonpath='{.spec.containers[0].resources.requests.cpu}')" 50m && eq "$($p -o jsonpath='{.metadata.labels.role}')" edge"""),
C("manifest lives in the kubelet's actual staticPodPath", 2, r"""d=$(nx "$W3" 'grep staticPodPath /var/lib/kubelet/config.yaml' | awk '{print $2}'); nx "$W3" "grep -ls %{pod} $d/*.yaml" >/dev/null 2>&1"""),
],
},
{
"id": 104, "domain": "ARCH", "difficulty": "hard", "title": "User onboarding to a working kubeconfig",
"params": lambda r: {"ctx": "k8s-c1-H", "user": r.choice(["maya", "theo", "iris"]), "ns": ns_pick(r, "dev")},
"task": CTX + """
Onboard user `%{user}` (group `developers`) end to end:
1. Key + CSR with CN=%{user}, O=developers; Kubernetes CSR object `%{user}`, approved, certificate issued.
2. A **self-contained** kubeconfig at `{WS}/q104/%{user}.kubeconfig` (certs embedded, not file paths)
with a context `%{user}@lab` whose default namespace is `%{ns}`.
3. `%{user}` may do everything to pods, deployments and services in `%{ns}` (edit-level), nothing in other namespaces.
`kubectl --kubeconfig {WS}/q104/%{user}.kubeconfig get pods` must work with no extra flags.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q104"; mkdir -p "$WS/q104"; $K delete csr %{user} --ignore-not-found >/dev/null; $K -n %{ns} delete rolebinding --all >/dev/null 2>&1""",
"checks": [
C("CSR %{user} approved with certificate, O=developers", 2, r"""[ -n "$(j csr %{user} '{.status.certificate}')" ] && j csr %{user} '{.status.certificate}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'O *= *developers'"""),
C("kubeconfig is self-contained and works without flags", 3, r"""f="$WS/q104/%{user}.kubeconfig"; [ -f "$f" ] || exit 1; grep -q 'client-certificate-data' "$f" && grep -q 'certificate-authority-data' "$f" && ! grep -q 'client-certificate:' "$f" && kubectl --kubeconfig "$f" get pods >/dev/null 2>&1"""),
C("context %{user}@lab defaults to %{ns}", 1, r"""eq "$(kubectl --kubeconfig "$WS/q104/%{user}.kubeconfig" config view -o jsonpath='{.contexts[?(@.name=="%{user}@lab")].context.namespace}')" %{ns}"""),
C("edit rights in %{ns}, none elsewhere", 2, r"""eq "$($K auth can-i create deployments -n %{ns} --as=%{user} 2>/dev/null)" yes && eq "$($K auth can-i delete pods -n %{ns} --as=%{user} 2>/dev/null)" yes && eq "$($K auth can-i get pods -n default --as=%{user} 2>/dev/null || true)" no && eq "$($K auth can-i get secrets -n kube-system --as=%{user} 2>/dev/null || true)" no"""),
],
},
{
"id": 105, "domain": "ARCH", "difficulty": "hard", "title": "Kustomize with generators and patches",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "kz"), "rep": r.choice([3, 4]), "lvl": r.choice(["debug", "trace", "warn"])},
"task": CTX + """
Base at `{WS}/q105/base` (Deployment `web`, Service `web`). Build overlay `{WS}/q105/overlays/prod` that:
- targets namespace `%{ns}` and adds label `tier=prod` to all resources (labels only, not selectors),
- sets replicas to %{rep} via a **strategic merge patch** file `replicas.yaml`,
- adds a `configMapGenerator` named `web-config` with literal `LOG_LEVEL=%{lvl}` (hash suffix enabled),
- injects that ConfigMap into the container via `envFrom` (JSON 6902 patch file `envfrom.yaml`).
Apply with `kubectl apply -k`. The Deployment must reference the hashed ConfigMap name.
""",
"setup": r"""
rm -rf "$WS/q105"; mkdir -p "$WS/q105/base"
cat > "$WS/q105/base/kustomization.yaml" <<'EOF'
resources: [deployment.yaml, service.yaml]
EOF
cat > "$WS/q105/base/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: web}
spec:
replicas: 1
selector: {matchLabels: {app: web}}
template:
metadata: {labels: {app: web}}
spec:
containers: [{name: web, image: nginx:1.25, ports: [{containerPort: 80}]}]
EOF
cat > "$WS/q105/base/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata: {name: web}
spec: {selector: {app: web}, ports: [{port: 80}]}
EOF
$K delete ns %{ns} --ignore-not-found --wait=false >/dev/null
""",
"checks": [
C("overlay has replicas.yaml (SMP) and envfrom.yaml (JSON6902)", 1, r"""[ -f "$WS/q105/overlays/prod/replicas.yaml" ] && [ -f "$WS/q105/overlays/prod/envfrom.yaml" ] && grep -q 'op:' "$WS/q105/overlays/prod/envfrom.yaml" """),
C("deployed to %{ns}: %{rep} replicas, tier=prod label, selector untouched", 2, r"""d="$K -n %{ns} get deploy web"; eq "$($d -o jsonpath='{.spec.replicas}' 2>/dev/null)" %{rep} && eq "$($d -o jsonpath='{.metadata.labels.tier}')" prod && [ -z "$($d -o jsonpath='{.spec.selector.matchLabels.tier}')" ]"""),
C("hashed ConfigMap with LOG_LEVEL=%{lvl}, referenced via envFrom", 3, r"""cm=$(j -n %{ns} deploy web '{.spec.template.spec.containers[0].envFrom[0].configMapRef.name}'); [ -n "$cm" ] && echo "$cm" | grep -Eq '^web-config-[a-z0-9]{6,}$' && eq "$(j -n %{ns} cm "$cm" '{.data.LOG_LEVEL}')" %{lvl}"""),
C("pods available and env visible", 1, r"""eq "$(j -n %{ns} deploy web '{.status.availableReplicas}')" %{rep} && $K -n %{ns} exec deploy/web -- env 2>/dev/null | grep -q '^LOG_LEVEL=%{lvl}$'"""),
],
},
{
"id": 106, "domain": "ARCH", "difficulty": "hard", "title": "CRD with validation and defaults",
"params": lambda r: {"ctx": ctx_pick(r), "kind": r.choice(["Rollout", "Canary", "Release"]), "env": r.choice(["prod", "stage"])},
"task": CTX + """
Create CRD `%{kind}` (group `deploy.example.com`, v1, Namespaced, plural lowercase+s, shortName `%{kind}`-initials) with schema:
- `spec.replicas` integer, **required**, minimum 1, maximum 10,
- `spec.environment` string, enum `prod|stage|dev`, **default** `dev`,
- `spec.image` string, required.
Then:
1. Create `%{kind}` `good` in `default` with replicas 3, image `nginx:1.27`, environment `%{env}`.
2. Save the API server's rejection message for a `%{kind}` with `replicas: 0` to `{WS}/q106/rejected.txt`.
3. Create `%{kind}` `defaulted` with only replicas 2 and image set; its environment must be `dev` after creation.
""",
"setup": r"""rm -rf "$WS/q106"; mkdir -p "$WS/q106"; k=$(echo %{kind} | tr A-Z a-z); $K delete crd ${k}s.deploy.example.com --ignore-not-found >/dev/null""",
"checks": [
C("CRD exists, Namespaced, replicas has min/max and is required", 2, r"""k=$(echo %{kind} | tr A-Z a-z); c="$K get crd ${k}s.deploy.example.com"; eq "$($c -o jsonpath='{.spec.scope}' 2>/dev/null)" Namespaced && s=$($c -o jsonpath='{.spec.versions[0].schema.openAPIV3Schema.properties.spec}'); echo "$s" | grep -q '"minimum":1' && echo "$s" | grep -q '"maximum":10' && echo "$s" | grep -q '"required":\[' """),
C("good created with environment %{env}", 1, r"""k=$(echo %{kind} | tr A-Z a-z); eq "$(j -n default ${k}s.deploy.example.com good '{.spec.environment}')" %{env}"""),
C("replicas 0 is rejected and the message was saved", 2, r"""grep -qiE 'minimum|greater than|>= 1|invalid' "$WS/q106/rejected.txt" 2>/dev/null"""),
C("defaulting works: defaulted.spec.environment == dev", 2, r"""k=$(echo %{kind} | tr A-Z a-z); eq "$(j -n default ${k}s.deploy.example.com defaulted '{.spec.environment}')" dev"""),
],
},
{
"id": 122, "domain": "ARCH", "breaks": "certs", "difficulty": "hard", "title": "Renew control-plane certificates",
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
Security policy requires the control-plane certificates to be renewed now.
On the control-plane node, renew **all** kubeadm-managed certificates, make every control-plane
component pick up the new certificates, and update the admin kubeconfig on the node
(`/etc/kubernetes/admin.conf`) so it uses the renewed client certificate.
Write the new `apiserver` certificate's expiry (as printed by `kubeadm certs check-expiration`) to `{WS}/q122/expiry.txt`.
""",
"setup": r"""rm -rf "$WS/q122"; mkdir -p "$WS/q122"; nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' > "$WS/.q122-before" """,
"checks": [
C("apiserver certificate was renewed (newer notBefore)", 3, r"""b=$(sed 's/notBefore=//' "$WS/.q122-before"); a=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' | sed 's/notBefore=//'); [ "$(date -d "$a" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
C("all components restarted and Running with the new certs (apiserver serving cert matches file)", 3, r"""
for c in kube-apiserver kube-scheduler kube-controller-manager etcd; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || exit 1; done
srv=$(nx "$CP" 'echo | openssl s_client -connect 127.0.0.1:6443 2>/dev/null | openssl x509 -noout -serial'); f=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -serial'); [ -n "$srv" ] && eq "$srv" "$f" """),
C("admin.conf uses the renewed client cert", 2, r"""nx "$CP" 'grep client-certificate-data /etc/kubernetes/admin.conf | awk "{print \$2}" | base64 -d | openssl x509 -noout -startdate' | sed 's/notBefore=//' > /tmp/q122a; b=$(sed 's/notBefore=//' "$WS/.q122-before"); [ "$(date -d "$(cat /tmp/q122a)" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
C("expiry.txt matches kubeadm's report for apiserver", 1, r"""exp=$(nx "$CP" 'kubeadm certs check-expiration 2>/dev/null' | awk '$1=="apiserver"{print $2,$3,$4,$5,$6}'); [ -n "$exp" ] && grep -qF "$exp" "$WS/q122/expiry.txt" 2>/dev/null"""),
],
},
# ================================================================ WORK ====
{
"id": 107, "domain": "WORK", "breaks": "w1-taint", "difficulty": "hard", "title": "Spread, tolerate, prioritise — under a quota",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "spread"), "dep": name_pick(r, "gateway"), "key": r.choice(["pool", "lane"]), "val": r.choice(["ingress", "edge"])},
"task": CTX + """
Node `{W1}` is tainted `%{key}=%{val}:NoSchedule`. In namespace `%{ns}` create Deployment `%{dep}`
(nginx:1.27, **3 replicas**) such that:
- exactly one pod runs on each worker node (required pod anti-affinity on `kubernetes.io/hostname`),
- it tolerates the taint on `{W1}`,
- it uses PriorityClass `%{dep}-prio` (value 50000, create it),
- requests cpu 100m / memory 64Mi.
All 3 pods must be Running. The namespace has a ResourceQuota — respect it or adjust it, but the
quota must still cap pods in the namespace.
""",
"setup": NS + r"""
mkns %{ns}
$K taint node "$W1" %{key}- >/dev/null 2>&1 || true; $K taint node "$W1" %{key}=%{val}:NoSchedule >/dev/null
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null; $K delete priorityclass %{dep}-prio --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: ResourceQuota
metadata: {name: %{ns}-quota, namespace: %{ns}}
spec: {hard: {pods: "2", requests.cpu: "250m"}}
EOF
""",
"checks": [
C("3 pods available, one per worker", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 && [ "$(j -n %{ns} pod -l app=%{dep} '{.items[*].spec.nodeName}' | tr ' ' '\n' | sort -u | wc -l)" = 3 ]"""),
C("required anti-affinity on hostname + toleration for %{key}", 2, r"""t=$(j -n %{ns} deploy %{dep} '{.spec.template.spec}'); echo "$t" | grep -q 'requiredDuringSchedulingIgnoredDuringExecution' && echo "$t" | grep -q '"topologyKey":"kubernetes.io/hostname"' && echo "$t" | grep -q '"key":"%{key}"' """),
C("PriorityClass %{dep}-prio (50000) in use", 1, r"""eq "$(j priorityclass %{dep}-prio '{.value}')" 50000 && eq "$(j -n %{ns} pod -l app=%{dep} '{.items[0].spec.priority}')" 50000"""),
C("quota still exists and caps pods (>=3)", 2, r"""p=$(j -n %{ns} quota %{ns}-quota '{.spec.hard.pods}'); [ -n "$p" ] && [ "$p" -ge 3 ]"""),
],
},
{
"id": 108, "domain": "WORK", "difficulty": "hard", "title": "Rollback to a specific revision, then pause",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "rel"), "dep": name_pick(r, "portal")},
"task": CTX + """
Deployment `%{dep}` in `%{ns}` has gone through several releases; the current one is broken.
1. Roll it back to the revision that used image `nginx:1.25` (not simply the previous revision).
2. Scale it to 3 replicas and wait until available.
3. **Pause** the rollout, then change the image to `nginx:1.27` and add env `RELEASE=next`.
Because it is paused, no new ReplicaSet may be created and the running pods must stay on `nginx:1.25`.
Leave it paused.
""",
"setup": NS + r"""
mkns %{ns}; $K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.25 >/dev/null
$K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
$K -n %{ns} set image deploy %{dep} nginx=nginx:1.26 >/dev/null; $K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
$K -n %{ns} set image deploy %{dep} nginx=nginx:1.27 >/dev/null; $K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
$K -n %{ns} set image deploy %{dep} nginx=nginx:9.99-broken >/dev/null
""",
"checks": [
C("running pods use nginx:1.25 and 3 are available", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 && for i in $(j -n %{ns} pod -l app=%{dep} '{.items[*].spec.containers[0].image}'); do eq "$i" nginx:1.25 || exit 1; done"""),
C("deployment is paused with template image nginx:1.27 and RELEASE=next", 2, r"""d="$K -n %{ns} get deploy %{dep}"; eq "$($d -o jsonpath='{.spec.paused}' 2>/dev/null)" true && eq "$($d -o jsonpath='{.spec.template.spec.containers[0].image}')" nginx:1.27 && eq "$($d -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="RELEASE")].value}')" next"""),
C("no ReplicaSet exists for the paused template (1.27 + RELEASE)", 2, r"""! $K -n %{ns} get rs -o json 2>/dev/null | python3 -c '
import sys,json
for rs in json.load(sys.stdin)["items"]:
c=rs["spec"]["template"]["spec"]["containers"][0]
if c["image"]=="nginx:1.27" and any(e.get("name")=="RELEASE" for e in c.get("env",[])): sys.exit(0)
sys.exit(1)'"""),
],
},
{
"id": 109, "domain": "WORK", "difficulty": "hard", "title": "Native sidecar with probes and an exact report",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "side"), "pod": name_pick(r, "collector")},
"task": CTX + """
Create pod `%{pod}` in `%{ns}`:
- a **native sidecar** (an init container with `restartPolicy: Always`) named `agent` (busybox:1.36)
that appends `$(date)` to `/var/log/agent.log` every second,
- main container `app` (nginx:1.27) with a readiness probe (HTTP GET / on 80, period 5s)
and a liveness probe (TCP 80),
- shared `emptyDir` `logs` mounted at `/var/log` in both,
- `terminationGracePeriodSeconds: 5`.
When it is Running, write the names of **all** containers of the pod (init and regular), sorted,
one per line, to `{WS}/q109/containers.txt`, and the pod's `.status.podIP` to `{WS}/q109/ip.txt`.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q109"; mkdir -p "$WS/q109"; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null""",
"checks": [
C("native sidecar: initContainer agent with restartPolicy Always", 2, r"""eq "$(j -n %{ns} pod %{pod} '{.spec.initContainers[?(@.name=="agent")].restartPolicy}')" Always"""),
C("probes on app as specified", 2, r"""c='{.spec.containers[?(@.name=="app")]'; eq "$(j -n %{ns} pod %{pod} "$c.readinessProbe.httpGet.path}")" / && eq "$(j -n %{ns} pod %{pod} "$c.readinessProbe.periodSeconds}")" 5 && eq "$(j -n %{ns} pod %{pod} "$c.livenessProbe.tcpSocket.port}")" 80"""),
C("Running, shared log written by agent, grace period 5", 2, r"""eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && eq "$(j -n %{ns} pod %{pod} '{.spec.terminationGracePeriodSeconds}')" 5 && [ -n "$($K -n %{ns} exec %{pod} -c app -- sh -c 'tail -1 /var/log/agent.log' 2>/dev/null)" ]"""),
C("report files exact", 2, r"""want=$( (j -n %{ns} pod %{pod} '{.spec.initContainers[*].name}'; echo; j -n %{ns} pod %{pod} '{.spec.containers[*].name}') | tr ' ' '\n' | grep . | sort); [ "$(grep . "$WS/q109/containers.txt" 2>/dev/null | sort)" = "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q109/ip.txt" 2>/dev/null)" "$(j -n %{ns} pod %{pod} '{.status.podIP}')" """),
],
},
{
"id": 110, "domain": "WORK", "difficulty": "hard", "title": "Cluster audit to exact files",
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
Produce three files, contents **exactly** as specified (no headers, sorted with `sort`, one per line):
1. `{WS}/q110/no-readiness.txt` — `<namespace>/<pod>` for every pod in the cluster whose first
container has **no** readiness probe. Exclude namespace `kube-system`.
2. `{WS}/q110/per-node.txt` — `<node> <count>` of Running pods per node, all namespaces.
3. `{WS}/q110/images.txt` — every distinct container image running in namespace `kube-system`.
""",
"setup": r"""rm -rf "$WS/q110"; mkdir -p "$WS/q110" """,
"checks": [
C("no-readiness.txt exact", 3, r"""want=$($K get pods -A -o json 2>/dev/null | python3 -c '
import sys,json
for p in json.load(sys.stdin)["items"]:
if p["metadata"]["namespace"]=="kube-system": continue
if not p["spec"]["containers"][0].get("readinessProbe"): print(p["metadata"]["namespace"]+"/"+p["metadata"]["name"])' | sort); [ "$(grep . "$WS/q110/no-readiness.txt" 2>/dev/null | sort)" = "$want" ]"""),
C("per-node.txt exact", 2, r"""want=$($K get pods -A -o json 2>/dev/null | python3 -c '
import sys,json,collections
c=collections.Counter(p["spec"].get("nodeName") for p in json.load(sys.stdin)["items"] if p["status"]["phase"]=="Running" and p["spec"].get("nodeName"))
for n in sorted(c): print(n, c[n])'); [ "$(grep . "$WS/q110/per-node.txt" 2>/dev/null | sort)" = "$(echo "$want" | sort)" ]"""),
C("images.txt exact", 2, r"""want=$(j -n kube-system pods '{.items[*].spec.containers[*].image}' | tr ' ' '\n' | sort -u); [ "$(grep . "$WS/q110/images.txt" 2>/dev/null | sort -u)" = "$want" ]"""),
],
},
{
"id": 121, "domain": "WORK", "difficulty": "hard", "title": "The context's namespace",
"params": lambda r: {"ctx": "k8s-c2-AC", "ns": ns_pick(r, "ac"), "dep": name_pick(r, "ledger")},
"task": CTX + """
In the **current namespace of that context** (do not pass `-n`), create Deployment `%{dep}`
(busybox:1.36, `sleep 3600`, 2 replicas, label `owner=ac`) and a ClusterIP Service `%{dep}`
on port 9000 targeting port 9000. Then write the namespace name you deployed into, as reported
by `kubectl config view --minify`, to `{WS}/q121/ns.txt`.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q121"; mkdir -p "$WS/q121"; $K -n %{ns} delete deploy,svc %{dep} --ignore-not-found >/dev/null; $K -n default delete deploy,svc %{dep} --ignore-not-found >/dev/null""",
"checks": [
C("Deployment %{dep} in %{ns} (the context's namespace), 2 available, owner=ac", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 2 && eq "$(j -n %{ns} deploy %{dep} '{.spec.template.metadata.labels.owner}')" ac"""),
C("Service %{dep} port 9000 in the same namespace", 1, r"""eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].port}')" 9000"""),
C("nothing was created in namespace default by mistake", 1, r"""! $K -n default get deploy %{dep} >/dev/null 2>&1"""),
C("ns.txt has the namespace", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q121/ns.txt" 2>/dev/null)" %{ns}"""),
],
},
# ================================================================= NET ====
{
"id": 111, "domain": "NET", "difficulty": "hard", "title": "Zero-trust namespace",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "zt"), "from": ns_pick(r, "front")},
"task": CTX + """
Namespace `%{ns}` runs `api` (label `app=api`, port 8080) and `db` (label `app=db`, port 5432).
Implement zero trust with NetworkPolicies:
1. `default-deny`: deny all ingress **and** egress for every pod in `%{ns}`.
2. `allow-dns`: every pod may egress to DNS (UDP/TCP 53) in `kube-system` only.
3. `api-ingress`: `api` accepts ingress on 8080 only from pods in namespaces labelled `team=%{from}`.
4. `api-to-db`: `api` may egress to `db` on 5432; `db` accepts ingress on 5432 from `api` only.
Remove anything that contradicts this. Label namespace `%{from}` appropriately.
""",
"setup": NS + r"""
mkns %{ns}; mkns %{from}
$K -n %{ns} create deploy api --image=nginx:1.25 --port=8080 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} create deploy db --image=nginx:1.25 --port=5432 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} delete netpol --all >/dev/null 2>&1
# trap: an allow-all left behind
$K apply -f - >/dev/null <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: temp-allow-all, namespace: %{ns}}
spec: {podSelector: {}, policyTypes: [Ingress, Egress], ingress: [{}], egress: [{}]}
EOF
$K label ns %{from} team- >/dev/null 2>&1 || true
""",
"checks": [
C("default-deny covers Ingress and Egress for all pods; allow-all removed", 2, r"""n="$K -n %{ns} get netpol default-deny"; eq "$($n -o jsonpath='{.spec.podSelector}' 2>/dev/null)" '{}' && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress && $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress && [ -z "$($n -o jsonpath='{.spec.ingress}')" ] && [ -z "$($n -o jsonpath='{.spec.egress}')" ] && ! $K -n %{ns} get netpol temp-allow-all >/dev/null 2>&1"""),
C("allow-dns: egress 53 UDP+TCP to kube-system only", 2, r"""$K -n %{ns} get netpol allow-dns -o json 2>/dev/null | python3 -c '
import sys,json; s=json.load(sys.stdin)["spec"]; e=s.get("egress",[])
ok=s.get("podSelector")=={} and any({p.get("protocol") for p in r.get("ports",[]) if str(p.get("port"))=="53"}>={"UDP","TCP"} and all("kube-system" in json.dumps(t.get("namespaceSelector",{})) for t in r.get("to",[])) and r.get("to") for r in e)
sys.exit(0 if ok else 1)'"""),
C("api-ingress: from namespaces team=%{from} on 8080 only; namespace labelled", 2, r"""eq "$(j ns %{from} '{.metadata.labels.team}')" %{from} && $K -n %{ns} get netpol api-ingress -o json 2>/dev/null | python3 -c '
import sys,json; s=json.load(sys.stdin)["spec"]
ok=s["podSelector"].get("matchLabels",{}).get("app")=="api" and len(s.get("ingress",[]))==1 and all(str(p.get("port"))=="8080" for p in s["ingress"][0].get("ports",[])) and s["ingress"][0].get("ports") and all(t.get("namespaceSelector",{}).get("matchLabels",{}).get("team")=="%{from}" for t in s["ingress"][0].get("from",[])) and s["ingress"][0].get("from")
sys.exit(0 if ok else 1)'"""),
C("api-to-db: api egress to db:5432 and db ingress from api:5432", 2, r"""$K -n %{ns} get netpol -o json 2>/dev/null | python3 -c '
import sys,json; items=json.load(sys.stdin)["items"]; eg=ing=False
for n in items:
s=n["spec"]; app=s["podSelector"].get("matchLabels",{}).get("app")
for r in s.get("egress",[]):
if app=="api" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="db" for t in r.get("to",[])) and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): eg=True
for r in s.get("ingress",[]):
if app=="db" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="api" for t in r.get("from",[])) and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): ing=True
sys.exit(0 if eg and ing else 1)'"""),
],
},
{
"id": 112, "domain": "NET", "difficulty": "hard", "title": "TLS Ingress with a generated certificate",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "tls"), "host": r.choice(["secure.lab.local", "portal.lab.local", "admin.lab.local"])},
"task": CTX + """
Namespace `%{ns}` has Deployment+Service `web` (port 80).
1. Generate a self-signed certificate (RSA 2048, 365 days) with CN `%{host}` and store it as a
Secret `%{host}-tls` of type `kubernetes.io/tls`.
2. Create Ingress `web` (class `nginx`) serving host `%{host}` with that TLS secret, path `/` Prefix → `web:80`,
and the annotation `nginx.ingress.kubernetes.io/ssl-redirect: "true"`.
3. Write the certificate's SHA-256 fingerprint (`openssl x509 -fingerprint -sha256`, the value only) to `{WS}/q112/fp.txt`.
""",
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q112"; mkdir -p "$WS/q112"; $K -n %{ns} create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} delete secret %{host}-tls --ignore-not-found >/dev/null; $K -n %{ns} delete ingress web --ignore-not-found >/dev/null""",
"checks": [
C("Secret %{host}-tls is kubernetes.io/tls with CN %{host}", 2, r"""eq "$(j -n %{ns} secret %{host}-tls '{.type}')" kubernetes.io/tls && j -n %{ns} secret %{host}-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'CN *= *%{host}'"""),
C("Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", 3, r"""i="$K -n %{ns} get ingress web"; eq "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" nginx && eq "$($i -o jsonpath='{.spec.tls[0].secretName}')" %{host}-tls && eq "$($i -o jsonpath='{.spec.tls[0].hosts[0]}')" %{host} && eq "$($i -o jsonpath='{.spec.rules[0].host}')" %{host} && eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" web && eq "$($i -o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/ssl-redirect}')" true"""),
C("fp.txt matches the secret's certificate fingerprint", 2, r"""fp=$(j -n %{ns} secret %{host}-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//'); [ -n "$fp" ] && grep -qiF "$fp" "$WS/q112/fp.txt" 2>/dev/null"""),
],
},
{
"id": 113, "domain": "NET", "difficulty": "hard", "title": "Gateway API canary with header match",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "canary"), "host": r.choice(["shop", "pay", "cart"]), "w": r.choice([10, 20, 25])},
"task": CTX + """
Gateway API CRDs are installed. Namespace `%{ns}` has Services `v1` and `v2` (port 80).
Create in `%{ns}`:
- Gateway `%{host}-gw` (class `%{host}-gc`, create the GatewayClass, controllerName `example.com/gw`)
with two HTTP listeners: `web` for `%{host}.example.com` and `api` for `api.%{host}.example.com`, both port 80.
- HTTPRoute `%{host}-route` attached to listener `web` only (`sectionName`), with:
- a rule matching header `X-Canary: always` → `v2` (100%),
- a default rule splitting `v1` %{w2}% / `v2` %{w}% by weight.
""",
"params_post": None,
"setup": NS + r"""
mkns %{ns}
$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"
for v in v1 v2; do $K -n %{ns} create deploy $v --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} expose deploy $v --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; done
$K delete gatewayclass %{host}-gc --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete gateway %{host}-gw --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete httproute %{host}-route --ignore-not-found >/dev/null 2>&1
""",
"checks": [
C("Gateway with listeners web and api on the right hostnames", 2, r"""g="$K -n %{ns} get gateway %{host}-gw"; eq "$($g -o jsonpath='{.spec.gatewayClassName}' 2>/dev/null)" %{host}-gc && eq "$($g -o jsonpath='{.spec.listeners[?(@.name=="web")].hostname}')" %{host}.example.com && eq "$($g -o jsonpath='{.spec.listeners[?(@.name=="api")].hostname}')" api.%{host}.example.com"""),
C("route attaches to listener web only (sectionName)", 1, r"""eq "$(j -n %{ns} httproute %{host}-route '{.spec.parentRefs[0].sectionName}')" web && [ -z "$(j -n %{ns} httproute %{host}-route '{.spec.parentRefs[1]}')" ]"""),
C("header rule X-Canary: always -> v2", 2, r"""$K -n %{ns} get httproute %{host}-route -o json 2>/dev/null | python3 -c '
import sys,json; r=json.load(sys.stdin)["spec"]["rules"]
ok=any(any(any(h.get("name","").lower()=="x-canary" and h.get("value")=="always" for h in m.get("headers",[])) for m in x.get("matches",[])) and x["backendRefs"][0]["name"]=="v2" and len(x["backendRefs"])==1 for x in r)
sys.exit(0 if ok else 1)'"""),
C("default rule weights v1 %{w2} / v2 %{w}", 2, r"""$K -n %{ns} get httproute %{host}-route -o json 2>/dev/null | python3 -c '
import sys,json; r=json.load(sys.stdin)["spec"]["rules"]
ok=any(not x.get("matches") or all(not m.get("headers") for m in x["matches"]) and {b["name"]:b.get("weight") for b in x["backendRefs"]}=={"v1":%{w2},"v2":%{w}} for x in r if len(x.get("backendRefs",[]))==2)
sys.exit(0 if ok else 1)'"""),
],
},
{
"id": 114, "domain": "NET", "breaks": "coredns", "difficulty": "hard", "title": "DNS down: three faults", "disruptive": True,
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
Nothing in the cluster resolves DNS. Restore it. There is more than one cause.
When done, `nslookup kubernetes.default` and `nslookup kubernetes.io` must succeed from a pod
in `default`, CoreDNS must have 2 available replicas, and no NetworkPolicy may remain in `kube-system`.
""",
"setup": r"""
$K -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' > /tmp/Corefile.q114
sed -i 's|forward \. /etc/resolv.conf|forward . 10.255.255.254|' /tmp/Corefile.q114
$K -n kube-system create cm coredns --from-file=Corefile=/tmp/Corefile.q114 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n kube-system scale deploy coredns --replicas=0 >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: maintenance-lockdown, namespace: kube-system}
spec: {podSelector: {matchLabels: {k8s-app: kube-dns}}, policyTypes: [Ingress], ingress: []}
EOF
""",
"checks": [
C("Corefile forwards to /etc/resolv.conf", 2, r"""j -n kube-system cm coredns '{.data.Corefile}' | grep -q 'forward \. /etc/resolv.conf'"""),
C("CoreDNS has 2 available replicas", 2, r"""for i in $(seq 1 12); do [ "$(j -n kube-system deploy coredns '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 5; done; exit 1"""),
C("no NetworkPolicy in kube-system", 1, r"""[ "$($K -n kube-system get netpol --no-headers 2>/dev/null | wc -l)" = 0 ]"""),
C("resolution works from a pod", 3, r"""
$K -n default delete pod dns-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default run dns-probe --image=busybox:1.36 --restart=Never -- sh -c 'for i in 1 2 3 4 5 6; do nslookup kubernetes.default >/dev/null 2>&1 && nslookup kubernetes.io >/dev/null 2>&1 && exit 0; sleep 5; done; exit 1' >/dev/null 2>&1
for i in $(seq 1 20); do ph=$(j -n default pod dns-probe '{.status.phase}'); [ "$ph" = Succeeded ] && { $K -n default delete pod dns-probe --wait=false >/dev/null; exit 0; }; [ "$ph" = Failed ] && break; sleep 3; done
$K -n default delete pod dns-probe --wait=false >/dev/null; exit 1"""),
],
},
# ================================================================ STOR ====
{
"id": 115, "domain": "STOR", "difficulty": "hard", "title": "Bind the right local volume",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "lv"), "pvc": name_pick(r, "fast-data")},
"task": CTX + """
Two PersistentVolumes of class `local-fast` exist. Only one of them lives on the node labelled
`disk=nvme`. Create PVC `%{pvc}` in `%{ns}` (RWO, 1Gi, class `local-fast`) that binds to **that**
PV — use a label selector, do not edit the PVs — and pod `consumer` (busybox:1.36, `sleep 3600`)
mounting it at `/data`. The pod must run on the nvme node. Do not delete either PV.
""",
"setup": NS + r"""
mkns %{ns}
for n in "$W1" "$W2" "$W3"; do $K label node $n disk- >/dev/null 2>&1 || true; done
$K label node "$W2" disk=nvme --overwrite >/dev/null
$K -n %{ns} delete pod consumer --ignore-not-found --wait=false >/dev/null; $K -n %{ns} delete pvc %{pvc} --ignore-not-found >/dev/null
$K delete pv lv-hdd lv-nvme --ignore-not-found >/dev/null
for pair in "lv-hdd:$W1:hdd:2Gi" "lv-nvme:$W2:nvme:1Gi"; do IFS=: read name node kind size <<<"$pair"
nx "$node" "mkdir -p /mnt/$name"
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolume
metadata: {name: $name, labels: {media: $kind}}
spec:
capacity: {storage: $size}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: local-fast
local: {path: /mnt/$name}
nodeAffinity: {required: {nodeSelectorTerms: [{matchExpressions: [{key: kubernetes.io/hostname, operator: In, values: [$node]}]}]}}
EOF
done
""",
"checks": [
C("PVC uses a selector and is Bound to lv-nvme", 3, r"""[ -n "$(j -n %{ns} pvc %{pvc} '{.spec.selector}')" ] && eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" lv-nvme && eq "$(j -n %{ns} pvc %{pvc} '{.status.phase}')" Bound"""),
C("consumer Running on the nvme node with /data mounted", 2, r"""eq "$(j -n %{ns} pod consumer '{.status.phase}')" Running && eq "$(j -n %{ns} pod consumer '{.spec.nodeName}')" "$W2" && j -n %{ns} pod consumer '{.spec.containers[0].volumeMounts[*].mountPath}' | grep -qw /data"""),
C("both PVs still exist, unmodified affinity", 1, r"""eq "$(j pv lv-hdd '{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}')" "$W1" && eq "$(j pv lv-nvme '{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}')" "$W2" """),
],
},
{
"id": 116, "domain": "STOR", "difficulty": "hard", "title": "Migrate data between claims",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "mig"), "n": r.choice([7, 11, 13])},
"task": CTX + """
In `%{ns}`, PVC `old-data` holds files a running pod `legacy` wrote (`/data`). Storage is being
replaced:
1. Create PVC `new-data` (default StorageClass, 1Gi, RWO).
2. Copy **everything** from `old-data` to `new-data` using a Job named `migrate` (busybox:1.36).
The Job must complete; use whatever mounts you need.
3. Re-point pod `legacy` to `new-data` (same name, same namespace, mount at `/data`) and get it Running.
4. Delete PVC `old-data` afterwards. Its PV must be released, not deleted (check its reclaim policy first).
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod legacy --ignore-not-found --wait=true >/dev/null; $K -n %{ns} delete job migrate --ignore-not-found >/dev/null; $K -n %{ns} delete pvc old-data new-data --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: old-data, namespace: %{ns}}
spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 1Gi}}}
---
apiVersion: v1
kind: Pod
metadata: {name: legacy, namespace: %{ns}}
spec:
containers: [{name: app, image: busybox:1.36, command: [sh, -c, "for i in \$(seq 1 %{n}); do echo record-\$i > /data/file-\$i.txt; done; sleep 3600"], volumeMounts: [{name: d, mountPath: /data}]}]
volumes: [{name: d, persistentVolumeClaim: {claimName: old-data}}]
EOF
for i in $(seq 1 30); do [ "$(j -n %{ns} pod legacy '{.status.phase}')" = Running ] && break; sleep 2; done
pv=$(j -n %{ns} pvc old-data '{.spec.volumeName}'); [ -n "$pv" ] && $K patch pv $pv -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' >/dev/null
echo "$pv" > "$WS/.q116-pv"
""",
"checks": [
C("Job migrate completed", 2, r"""eq "$(j -n %{ns} job migrate '{.status.succeeded}')" 1"""),
C("legacy Running on new-data with all %{n} files intact", 3, r"""eq "$(j -n %{ns} pod legacy '{.status.phase}')" Running && eq "$(j -n %{ns} pod legacy '{.spec.volumes[?(@.name!="kube-api-access")].persistentVolumeClaim.claimName}' | tr ' ' '\n' | grep -v '^$' | head -1)" new-data && eq "$($K -n %{ns} exec legacy -- sh -c 'ls /data/file-*.txt | wc -l' 2>/dev/null | tr -d ' ')" %{n} && eq "$($K -n %{ns} exec legacy -- cat /data/file-%{n}.txt 2>/dev/null)" record-%{n}"""),
C("old-data deleted; its PV Released (not deleted)", 2, r"""! $K -n %{ns} get pvc old-data >/dev/null 2>&1 && pv=$(cat "$WS/.q116-pv" 2>/dev/null) && eq "$(j pv $pv '{.status.phase}')" Released"""),
],
},
# ============================================================== TSHOOT ====
{
"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
enabled, and a new pod pinned to it must reach Running. Nothing else on the cluster may be changed.
""",
"setup": r"""
$K cordon "$W2" >/dev/null 2>&1 || true
nx "$W2" '
sed -i "s|clientCAFile: /etc/kubernetes/pki/ca.crt|clientCAFile: /etc/kubernetes/pki/ca-wrong.crt|" /var/lib/kubelet/config.yaml
systemctl disable kubelet >/dev/null 2>&1; systemctl stop kubelet'
""",
"checks": [
C("node Ready", 3, r"""for i in $(seq 1 12); do [ "$(j node "$W2" '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && exit 0; sleep 5; done; exit 1"""),
C("node uncordoned (collateral check)", 2, r"""[ "$(j node "$W2" '{.spec.unschedulable}')" != true ]"""),
C("kubelet active and enabled, config points at a real CA", 2, r"""eq "$(nx "$W2" 'systemctl is-active kubelet')" active && eq "$(nx "$W2" 'systemctl is-enabled kubelet')" enabled && f=$(nx "$W2" 'grep clientCAFile /var/lib/kubelet/config.yaml' | awk '{print $2}') && nx "$W2" "test -f $f" """),
C("a pod pinned to the node runs", 2, r"""
$K -n default delete pod node-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default run node-probe --image=busybox:1.36 --restart=Never --overrides="{\"spec\":{\"nodeName\":\"$W2\"}}" -- sleep 120 >/dev/null 2>&1
ok=1; for i in $(seq 1 20); do [ "$(j -n default pod node-probe '{.status.phase}')" = Running ] && { ok=0; break; }; sleep 3; done
$K -n default delete pod node-probe --wait=false >/dev/null 2>&1; exit $ok"""),
],
},
{
"id": 118, "domain": "TSHOOT", "breaks": "scheduler", "difficulty": "hard", "title": "Two control-plane components down", "disruptive": True,
"params": lambda r: {"ctx": "k8s-c1-H"},
"task": CTX + """
New Deployments create no pods, and pods that do get created stay `Pending`. Two different
control-plane components are failing for two different reasons. Repair both on the control-plane node.
Afterwards a new Deployment must reach Available within a minute.
""",
"setup": r"""
nx "$CP" '
f=/etc/kubernetes/manifests/kube-scheduler.yaml; grep -q "^ *- kube-scheduler$" $f && sed -i "s|^\( *\)- kube-scheduler$|\1- kube-schedulerr|" $f
g=/etc/kubernetes/manifests/kube-controller-manager.yaml; grep -q "image: registry.k8s.io/kube-controller-manager:v" $g && sed -i "s|\(image: registry.k8s.io/kube-controller-manager:v[0-9.]*\)|\1-broken|" $g
'
""",
"checks": [
C("kube-scheduler manifest repaired and Running", 3, r"""nx "$CP" 'grep -q "^ *- kube-scheduler$" /etc/kubernetes/manifests/kube-scheduler.yaml' && eq "$(j -n kube-system pod -l component=kube-scheduler '{.items[0].status.phase}')" Running"""),
C("kube-controller-manager image repaired and Running", 3, r"""! nx "$CP" 'grep -q "\-broken" /etc/kubernetes/manifests/kube-controller-manager.yaml' && eq "$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].status.phase}')" Running"""),
C("a new Deployment becomes Available", 3, r"""
$K -n default delete deploy cp-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default create deploy cp-probe --image=busybox:1.36 -- sleep 300 >/dev/null 2>&1
ok=1; for i in $(seq 1 20); do [ "$(j -n default deploy cp-probe '{.status.availableReplicas}')" = 1 ] && { ok=0; break; }; sleep 3; done
$K -n default delete deploy cp-probe --wait=false >/dev/null 2>&1; exit $ok"""),
],
},
{
"id": 119, "domain": "TSHOOT", "difficulty": "hard", "title": "Application down for four reasons",
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "checkout"), "dep": name_pick(r, "checkout")},
"task": CTX + """
Deployment `%{dep}` in `%{ns}` should have 3 Available replicas and Service `%{dep}` should route
to them on port 80. Right now nothing works. Fix it **in place** — the Deployment must keep its
UID (do not delete and recreate it) and the Service must keep its ClusterIP.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy,svc,quota,secret --all >/dev/null 2>&1
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: ResourceQuota
metadata: {name: tight, namespace: %{ns}}
spec: {hard: {pods: "1"}}
---
apiVersion: v1
kind: Secret
metadata: {name: %{dep}-env, namespace: %{ns}}
stringData: {API_KEY: abc123}
---
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 3
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
containers:
- name: web
image: nginx:1.27-nope
ports: [{containerPort: 80}]
env: [{name: DB_URL, valueFrom: {secretKeyRef: {name: %{dep}-env, key: DATABASE_URL}}}]
resources: {requests: {cpu: 10m, memory: 16Mi}}
---
apiVersion: v1
kind: Service
metadata: {name: %{dep}, namespace: %{ns}}
spec:
selector: {app: %{dep}-web}
ports: [{port: 80, targetPort: 8080}]
EOF
j -n %{ns} deploy %{dep} '{.metadata.uid}' > "$WS/.q119-uid"; j -n %{ns} svc %{dep} '{.spec.clusterIP}' > "$WS/.q119-ip"
""",
"checks": [
C("3 replicas Available", 3, r"""for i in $(seq 1 6); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 3 ] && exit 0; sleep 5; done; exit 1"""),
C("Service routes: endpoints ready, targetPort 80", 2, r"""eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].targetPort}')" 80 && [ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{dep} '{.items[*].endpoints[?(@.conditions.ready==true)].addresses[*]}')" ]"""),
C("Deployment UID and Service ClusterIP unchanged (fixed in place)", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.metadata.uid}')" "$(cat "$WS/.q119-uid")" && eq "$(j -n %{ns} svc %{dep} '{.spec.clusterIP}')" "$(cat "$WS/.q119-ip")" """),
C("quota still present and permits the 3 pods", 1, r"""p=$(j -n %{ns} quota tight '{.spec.hard.pods}'); [ -n "$p" ] && [ "$p" -ge 3 ]"""),
],
},
{
"id": 120, "domain": "TSHOOT", "difficulty": "hard", "title": "Cluster facts, exactly",
"params": lambda r: {"ctx": "k8s-c1-H", "node": r.choice(["{W1}", "{W2}", "{W3}"])},
"task": CTX + """
Write `{WS}/q120/facts.txt` with these lines, values taken from the running cluster:
```
pod-cidr=<cluster pod CIDR as configured on kube-controller-manager>
service-cidr=<service CIDR as configured on kube-apiserver>
dns-ip=<ClusterIP of the kube-dns Service>
proxy-mode=<kube-proxy mode from its ConfigMap (empty means iptables — write iptables)>
%{node}-pod-cidr=<.spec.podCIDR of that node>
```
""",
"setup": r"""rm -rf "$WS/q120"; mkdir -p "$WS/q120" """,
"checks": [
C("pod-cidr and service-cidr", 2, r"""f="$WS/q120/facts.txt"; pc=$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'cluster-cidr=[^"]*' | cut -d= -f2); 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 -qx "pod-cidr=$pc" "$f" 2>/dev/null && grep -qx "service-cidr=$sc" "$f" """),
C("dns-ip and proxy-mode", 2, r"""f="$WS/q120/facts.txt"; d=$(j -n kube-system svc kube-dns '{.spec.clusterIP}'); m=$(j -n kube-system cm kube-proxy '{.data.config\.conf}' | grep '^mode:' | awk '{print $2}' | tr -d '"'); [ -n "$m" ] || m=iptables; grep -qx "dns-ip=$d" "$f" 2>/dev/null && grep -qx "proxy-mode=$m" "$f" """),
C("%{node} pod CIDR", 1, r"""grep -qx "%{node}-pod-cidr=$(j node %{node} '{.spec.podCIDR}')" "$WS/q120/facts.txt" 2>/dev/null"""),
],
},
]
# derived params: w2 = 100 - w for the canary split
for _q in QUESTIONS:
if _q["id"] == 113:
_orig = _q["params"]
_q["params"] = lambda r, _o=_orig: (lambda p: {**p, "w2": 100 - p["w"]})(_o(r))

1048
tracks/cka/bank_sim_a.py Normal file

File diff suppressed because it is too large Load Diff

973
tracks/cka/bank_sim_b.py Normal file
View File

@ -0,0 +1,973 @@
"""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: <etcd data directory>
server-key: <path to the server private key>
trusted-ca: <path to the CA used for client certificate verification>
client-cert-auth: <yes|no>
server-cert-expiry: <notAfter value of the etcd server certificate>
```
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 <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: ServiceCIDR
metadata: {name: extra-range}
spec: {cidrs: ["10.180.0.0/16"]}
EOF
k get servicecidr extra-range -o yaml # wait for Ready=True
k create deploy cidr-demo --image=nginx:1-alpine
k expose deploy cidr-demo --port=80 --cluster-ip=10.180.0.50
k get svc cidr-demo -o jsonpath='{.spec.clusterIP}' > {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=<value>` and `iat=<value>` 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 <<EOF | k apply -f -
apiVersion: v1
kind: Secret
metadata:
name: reporter-legacy
namespace: project-token
annotations: {kubernetes.io/service-account.name: reporter}
type: kubernetes.io/service-account-token
EOF
k -n project-token get secret reporter-legacy -o yaml # the controller fills in .data.token
# Bound tokens expire and are tied to an audience; the Secret form never expires - avoid it
# unless something really needs a static token.
""",
)
# ------------------------------------------------------------------ 305 ----
task(
id=305, domain="WORK", title="Schedule without the scheduler",
task="""
In Namespace `project-orion`:
1. Create Pod `direct-assign` (image `busybox:1.36`, command `sleep 86400`) that runs on node
`{W3}` **without the scheduler being involved at all**.
2. Create Pod `scheduled` (same image and command) that lands on `{W3}` **through** the scheduler.
3. Write to `{WS}/q305/proof.txt` the value of the field you used for Pod 1, in the form
`field=<name>`, and on a second line `scheduler=<the schedulerName of the second Pod>`.
""",
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
$K label node "$W3" tier- >/dev/null 2>&1 || true
""",
checks=[
C("direct-assign Running on the right node with spec.nodeName set", 3, r"""
eq "$(j -n project-orion pod direct-assign '{.status.phase}')" Running && eq "$(j -n project-orion pod direct-assign '{.spec.nodeName}')" "$W3" """),
C("direct-assign was never scheduled (no Scheduled event from default-scheduler)", 3, r"""
! $K -n project-orion get events --field-selector involvedObject.name=direct-assign 2>/dev/null | grep -q 'default-scheduler'"""),
C("scheduled Running on the same node via the default scheduler", 3, r"""
eq "$(j -n project-orion pod scheduled '{.status.phase}')" Running && eq "$(j -n project-orion pod scheduled '{.spec.nodeName}')" "$W3" \
&& $K -n project-orion get events --field-selector involvedObject.name=scheduled 2>/dev/null | grep -q 'default-scheduler'"""),
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 <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: direct-assign, namespace: project-orion}
spec:
nodeName: {W3} # bypasses the scheduler entirely - the kubelet just picks it up
containers: [{name: c, image: busybox:1.36, command: [sleep, "86400"]}]
EOF
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: scheduled, namespace: project-orion}
spec:
nodeSelector: {kubernetes.io/hostname: {W3}} # the scheduler still makes the decision
containers: [{name: c, image: busybox:1.36, command: [sleep, "86400"]}]
EOF
k -n project-orion get events --field-selector involvedObject.name=direct-assign # no Scheduled event
k -n project-orion get events --field-selector involvedObject.name=scheduled # Scheduled by default-scheduler
printf 'field=nodeName\\nscheduler=default-scheduler\\n' > {WS}/q305/proof.txt
""",
)
# ------------------------------------------------------------------ 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 the claim's
`.status.capacity.storage` after the resize to `{WS}/q306/capacity.txt`.
""",
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"""),
C("claim requests 2Gi and reports at least 2Gi capacity", 4, r"""
eq "$(j -n project-orbit pvc growing '{.spec.resources.requests.storage}')" 2Gi || exit 1
for i in $(seq 1 12); do c=$(j -n project-orbit pvc growing '{.status.capacity.storage}'); [ "$c" = 2Gi ] && exit 0; sleep 5; done; exit 1"""),
C("capacity.txt records the new capacity", 1, r"""grep -q '2Gi' "$WS/q306/capacity.txt" 2>/dev/null"""),
],
solution="""
cat <<EOF | k apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: {name: expandable}
provisioner: rancher.io/local-path
volumeBindingMode: WaitForFirstConsumer
allowVolumeExpansion: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: growing, namespace: project-orbit}
spec:
accessModes: [ReadWriteOnce]
storageClassName: expandable
resources: {requests: {storage: 1Gi}}
---
apiVersion: v1
kind: Pod
metadata: {name: grower, namespace: project-orbit}
spec:
volumes: [{name: d, persistentVolumeClaim: {claimName: growing}}]
containers: [{name: c, image: busybox:1.36, command: [sleep, "86400"], volumeMounts: [{name: d, mountPath: /data}]}]
EOF
k -n project-orbit patch pvc growing -p '{"spec":{"resources":{"requests":{"storage":"2Gi"}}}}'
k -n project-orbit get pvc growing -w # spec 2Gi first, then status.capacity follows
k -n project-orbit get pvc growing -o jsonpath='{.status.capacity.storage}' > {WS}/q306/capacity.txt
# Shrinking is never allowed. Without allowVolumeExpansion the patch is rejected outright.
""",
)
# ------------------------------------------------------------------ 307 ----
task(
id=307, domain="TSHOOT", title="Node accepts nothing new", breaks="w1-block",
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 <<EOF | $K apply -f - >/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:
```
<resource>|<shortname or ->|<apiVersion as served>|<namespaced true|false>
```
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 <resource> 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`.
""",
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 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 <<EOF | k apply -f -
apiVersion: v1
kind: Service
metadata: {name: external-db, namespace: project-orbit}
spec:
ports: [{name: pg, port: 5432, protocol: TCP, targetPort: 5432}]
# no selector: nothing populates the endpoints automatically, so we do it ourselves
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: external-db-1
namespace: project-orbit
labels: {kubernetes.io/service-name: external-db} # this label is the link to the Service
addressType: IPv4
ports: [{name: pg, port: 5432, protocol: TCP}]
endpoints:
- addresses: ["10.190.7.25"]
conditions: {ready: true}
EOF
k -n project-orbit get svc external-db -o jsonpath='{.spec.clusterIP}' > {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
```
<namespace>/<deployment> <image>[,<image>...]
```
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=[
C("images.txt matches the cluster exactly", 5, r"""
want=$($K get deploy -A -o json 2>/dev/null | python3 -c '
import sys,json
out=[]
for d in json.load(sys.stdin)["items"]:
s=d["spec"]["template"]["spec"]
imgs=[c["image"] for c in s.get("initContainers",[])]+[c["image"] for c in s["containers"]]
out.append("%s/%s %s"%(d["metadata"]["namespace"],d["metadata"]["name"],",".join(imgs)))
print("\n".join(sorted(out)))')
[ "$(grep . "$WS/q311/images.txt" 2>/dev/null | sort)" = "$want" ]"""),
C("distinct.txt has the right count", 3, r"""
want=$($K get deploy -A -o json 2>/dev/null | python3 -c '
import sys,json
s=set()
for d in json.load(sys.stdin)["items"]:
sp=d["spec"]["template"]["spec"]
for c in sp.get("initContainers",[])+sp["containers"]: s.add(c["image"])
print(len(s))')
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 `<Type>/<Reason>`, 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 <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: {name: multi, namespace: project-r700}
spec:
ingressClassName: nginx
defaultBackend: {service: {name: web-classic, port: {number: 80}}}
tls:
- hosts: [classic.r700.io, next.r700.io]
secretName: classic-tls
rules:
- host: classic.r700.io
http: {paths: [{path: /, pathType: Prefix, backend: {service: {name: web-classic, port: {number: 80}}}}]}
- host: next.r700.io
http: {paths: [{path: /, pathType: Prefix, backend: {service: {name: web-next, port: {number: 80}}}}]}
EOF
# `kubectl create ingress` can do most of this:
# k -n project-r700 create ingress multi --class=nginx \\
# --rule="classic.r700.io/*=web-classic:80,tls=classic-tls" \\
# --rule="next.r700.io/*=web-next:80,tls=classic-tls" \\
# --default-backend=web-classic:80
""",
)
# ------------------------------------------------------------------ 314 ----
task(
id=314, domain="TSHOOT", title="A static pod that never starts", breaks="w3-kubelet",
task="""
Someone added a static Pod manifest on node `{W3}` for a Pod called `edge-cache`, but no such Pod
ever appears in the cluster and the node is otherwise healthy.
Find out why, fix it on the node, and get the mirror Pod `edge-cache-{W3}` Running in Namespace
`default` with image `nginx:1.27`.
Write the one-line reason it wasn't starting to `{WS}/q314/reason.txt`.
""",
setup=r"""
rm -rf "$WS/q314"; mkdir -p "$WS/q314"
nx "$W3" 'rm -f /etc/kubernetes/manifests/edge-cache.yaml
d=$(grep staticPodPath /var/lib/kubelet/config.yaml | awk "{print \$2}")
mkdir -p "$d"
cat > "$d/edge-cache.yaml" <<EOF
apiVersion: v1
kind: Pod
metadata:
name: edge-cache
namespace: default
spec:
containers:
- name: cache
image: nginx:1.27
volumeMounts:
- name: conf
mountPath: /etc/nginx/conf.d
volumes:
- name: conf
hostPath:
path: /opt/edge-cache/conf
type: Directory
EOF
rm -rf /opt/edge-cache
systemctl restart kubelet'
""",
checks=[
C("mirror pod edge-cache-<node> 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"""),
C("the hostPath the manifest references now exists on the node", 3, r"""
p=$(nx "$W3" 'grep -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 <staticPodPath>/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 <control-plane> --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 <control-plane>
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 ----
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` with an empty `CONDITION`-issued certificate forever.
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 "cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt" $f && sed -i "s|--cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt|--cluster-signing-cert-file=/etc/kubernetes/pki/ca-signing.crt|" $f
'
""",
checks=[
C("kube-controller-manager points at a signing certificate that exists", 4, r"""
p=$(nx "$CP" 'grep -o "\-\-cluster-signing-cert-file=[^ ]*" /etc/kubernetes/manifests/kube-controller-manager.yaml' | cut -d= -f2)
[ -n "$p" ] && nx "$CP" "test -f $p" """),
C("kube-controller-manager pod is Running", 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 Issued
k -n kube-system logs kube-controller-manager-{CP} | tail -20
# ... unable to load client CA file .../ca-signing.crt: no such file or directory
{SHELL_CP}
grep signing /etc/kubernetes/manifests/kube-controller-manager.yaml
ls /etc/kubernetes/pki/ca.crt # the real file
vi /etc/kubernetes/manifests/kube-controller-manager.yaml
# --cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt
exit
openssl genrsa -out probe.key 2048
openssl req -new -key probe.key -subj "/CN=probe" -out probe.csr
cat <<EOF | k apply -f -
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata: {name: issue-probe}
spec: {request: $(base64 -w0 probe.csr), signerName: kubernetes.io/kube-apiserver-client, usages: [client auth]}
EOF
k certificate approve issue-probe
k get csr issue-probe # certificate now populated
# The csrsigning controller lives in kube-controller-manager: approval is the API's job,
# but issuing the certificate is the controller's.
""",
)
QUESTIONS = Q

15
tracks/cka/common.py Normal file
View File

@ -0,0 +1,15 @@
"""Shared helpers for CKA task banks."""
NS = r"""
mkns() { $K create ns "$1" --dry-run=client -o yaml | $K apply -f - >/dev/null; }
"""
def C(name, pts, script):
return {"name": name, "pts": pts, "script": script}
def ns_pick(r, base):
"""Namespace name with a random suffix so the same task never lands on the same namespace twice."""
return f"{base}-{r.choice(['alpha', 'bravo', 'delta', 'echo', 'kilo', 'nova', 'omega', 'zulu'])}"
def name_pick(r, base):
return f"{base}-{r.randint(10, 99)}"

652
tracks/cka/questions.py Normal file
View File

@ -0,0 +1,652 @@
"""
CKA track for the exam simulator.
TRACK - metadata (name, duration, pass mark, domain table)
QUESTIONS - list of tasks. Each task:
id, domain, title, task (markdown shown to candidate),
setup : bash that seeds the scenario
checks : list of (name, points, bash) - each check is graded independently
(exit 0 = pass). Points earned = sum of passing checks, so partial
credit works the way it does on the real exam.
disruptive: True => setup runs last, after everything else is scheduled.
Scripts get a prelude: $K (kubectl), $WS (workspace), $CP/$W1/$W2/$W3 (node
names), nx NODE 'cmd' (run a command on a node). {WS}, {CP}, {W2}, {W3},
{SHELL_CP} etc. are substituted in task text.
"""
TRACK = {
"id": "cka",
"name": "Certified Kubernetes Administrator",
"minutes": 120,
"pass_pct": 66,
# 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) - excluded from random sampling
"sets": {"a": "Simulator Set A", "b": "Simulator Set B"},
"domains": {
"ARCH": "Cluster Architecture, Installation & Configuration (25%)",
"WORK": "Workloads & Scheduling (15%)",
"NET": "Services & Networking (20%)",
"STOR": "Storage (10%)",
"TSHOOT": "Troubleshooting (30%)",
},
}
from tracks.cka.common import C, NS
QUESTIONS = [
# ================================================================ ARCH ====
{
"id": 1, "domain": "ARCH", "title": "RBAC for a deployment bot",
"task": """
Namespace `rbac-app` already exists.
1. Create a ServiceAccount `deploy-bot` in namespace `rbac-app`.
2. Create a Role `deploy-manager` in `rbac-app` that grants
get, list, watch, create, update, patch, delete on **deployments** (apps API group) only.
3. Bind it to the ServiceAccount with a RoleBinding named `deploy-bot-binding`.
The ServiceAccount must NOT be able to delete pods, and must have no access
outside the `rbac-app` namespace. Verify with `kubectl auth can-i`.
""",
"setup": NS + r"""
mkns rbac-app
$K -n rbac-app delete sa deploy-bot --ignore-not-found >/dev/null
$K -n rbac-app delete role deploy-manager --ignore-not-found >/dev/null
$K -n rbac-app delete rolebinding deploy-bot-binding --ignore-not-found >/dev/null
""",
"checks": [
C("ServiceAccount rbac-app/deploy-bot exists", 1, r"""$K -n rbac-app get sa deploy-bot >/dev/null 2>&1"""),
C("Role deploy-manager and RoleBinding deploy-bot-binding exist", 1,
r"""$K -n rbac-app get role deploy-manager >/dev/null 2>&1 && $K -n rbac-app get rolebinding deploy-bot-binding >/dev/null 2>&1"""),
C("deploy-bot can get/list/watch/create/update/patch/delete deployments", 2, r"""
for v in get list watch create update patch delete; do
[ "$($K auth can-i $v deployments -n rbac-app --as=system:serviceaccount:rbac-app:deploy-bot 2>/dev/null)" = yes ] || exit 1
done"""),
C("deploy-bot cannot delete pods", 1, r"""[ "$($K auth can-i delete pods -n rbac-app --as=system:serviceaccount:rbac-app:deploy-bot 2>/dev/null || true)" = no ]"""),
C("deploy-bot has no access in namespace default", 1, r"""[ "$($K auth can-i create deployments -n default --as=system:serviceaccount:rbac-app:deploy-bot 2>/dev/null || true)" = no ]"""),
],
},
{
"id": 2, "domain": "ARCH", "title": "etcd snapshot",
"task": """
Create a snapshot of the cluster's etcd database and save it on the
**control-plane node** at `/opt/cka/etcd-snapshot.db`.
Details you need:
- etcd listens on `https://127.0.0.1:2379`
- certificates are under `/etc/kubernetes/pki/etcd/` (`ca.crt`, `server.crt`, `server.key`)
- "ssh to the control-plane node" is: `{SHELL_CP}`
- Setup tried to place `etcdctl` at `/usr/local/bin/etcdctl` on that node.
If it is missing, run etcdctl inside the etcd pod
(`kubectl -n kube-system exec etcd-{CP} -- etcdctl ...`) and copy the file to the node afterwards.
Verify the snapshot with `etcdutl snapshot status` (or `etcdctl snapshot status`).
""",
"setup": r"""
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/etcd-snapshot.db'
POD=$($K -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].metadata.name}')
for bin in etcdctl etcdutl; do
if $K -n kube-system exec "$POD" -- cat /usr/local/bin/$bin > /tmp/cka-$bin 2>/dev/null && [ -s /tmp/cka-$bin ]; then
nx "$CP" "cat > /usr/local/bin/$bin && chmod +x /usr/local/bin/$bin" < /tmp/cka-$bin
fi
rm -f /tmp/cka-$bin
done
""",
"checks": [
C("/opt/cka/etcd-snapshot.db exists on the control-plane node", 3, r"""nx "$CP" 'test -f /opt/cka/etcd-snapshot.db'"""),
C("snapshot is a real etcd backup (> 100 KB)", 4, r"""[ "$(nx "$CP" 'stat -c %s /opt/cka/etcd-snapshot.db' 2>/dev/null || echo 0)" -gt 100000 ]"""),
],
},
{
"id": 3, "domain": "ARCH", "title": "Kustomize overlay",
"task": """
A Kustomize base lives in `{WS}/q03/base` (Deployment `store-api` + Service `store-api`).
Create a production overlay at `{WS}/q03/overlays/prod` that:
- deploys everything into namespace `store-prod` (create the Namespace as part of the overlay),
- adds the common label `env=prod` to all resources,
- sets the `store-api` Deployment to **3** replicas.
Apply it with `kubectl apply -k`.
""",
"setup": r"""
rm -rf "$WS/q03"; mkdir -p "$WS/q03/base"
cat > "$WS/q03/base/kustomization.yaml" <<'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
EOF
cat > "$WS/q03/base/deployment.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: store-api
spec:
replicas: 1
selector:
matchLabels:
app: store-api
template:
metadata:
labels:
app: store-api
spec:
containers:
- name: api
image: nginx:1.25
ports:
- containerPort: 80
EOF
cat > "$WS/q03/base/service.yaml" <<'EOF'
apiVersion: v1
kind: Service
metadata:
name: store-api
spec:
selector:
app: store-api
ports:
- port: 80
targetPort: 80
EOF
$K delete ns store-prod --ignore-not-found --wait=false >/dev/null
""",
"checks": [
C("overlay kustomization.yaml exists", 1, r"""[ -f "$WS/q03/overlays/prod/kustomization.yaml" ]"""),
C("resources deployed into namespace store-prod", 1, r"""$K -n store-prod get deploy store-api >/dev/null 2>&1 && $K -n store-prod get svc store-api >/dev/null 2>&1"""),
C("store-api has 3 replicas", 1, r"""[ "$($K -n store-prod get deploy store-api -o jsonpath='{.spec.replicas}')" = 3 ]"""),
C("Deployment and Service carry label env=prod", 2, r"""[ "$($K -n store-prod get deploy store-api -o jsonpath='{.metadata.labels.env}')" = prod ] && [ "$($K -n store-prod get svc store-api -o jsonpath='{.metadata.labels.env}')" = prod ]"""),
],
},
{
"id": 4, "domain": "ARCH", "title": "CustomResourceDefinition",
"task": """
1. Create a CRD with:
- group `data.example.com`, version `v1` (served + storage)
- kind `Backup`, plural `backups`, singular `backup`, shortName `bk`
- scope `Namespaced`
- an OpenAPI v3 schema where `spec` has string fields `schedule` and `target`
2. Create a `Backup` named `nightly` in namespace `default` with
`spec.schedule: "0 2 * * *"` and `spec.target: "s3://backups/prod"`.
`kubectl get bk` must list it.
""",
"setup": r"""$K delete crd backups.data.example.com --ignore-not-found >/dev/null""",
"checks": [
C("CRD backups.data.example.com exists and is Namespaced", 1, r"""[ "$($K get crd backups.data.example.com -o jsonpath='{.spec.scope}' 2>/dev/null)" = Namespaced ]"""),
C("shortName bk works", 1, r"""$K get crd backups.data.example.com -o jsonpath='{.spec.names.shortNames}' | grep -q '"bk"'"""),
C("Backup nightly has the right schedule", 1, r"""[ "$($K -n default get bk nightly -o jsonpath='{.spec.schedule}' 2>/dev/null)" = "0 2 * * *" ]"""),
C("Backup nightly has the right target", 1, r"""[ "$($K -n default get bk nightly -o jsonpath='{.spec.target}' 2>/dev/null)" = "s3://backups/prod" ]"""),
],
},
{
"id": 5, "domain": "ARCH", "title": "Static pod",
"task": """
Create a **static pod** named `static-web` running image `nginx:1.27`
on node `{W3}` (namespace `default`).
You must place the manifest in the kubelet's static pod path on that node.
Reach the node with: `{SHELL_W3}`
(check `/var/lib/kubelet/config.yaml` for `staticPodPath`).
The mirror pod `static-web-{W3}` must reach Running.
""",
"setup": r"""nx "$W3" 'rm -f /etc/kubernetes/manifests/static-web*.yaml' || true""",
"checks": [
C("manifest containing static-web is in /etc/kubernetes/manifests on the node", 2, r"""nx "$W3" 'grep -ls static-web /etc/kubernetes/manifests/*.yaml' >/dev/null 2>&1"""),
C("mirror pod static-web-<node> is Running", 2, r"""[ "$($K -n default get pod "static-web-$W3" -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ]"""),
C("image is nginx:1.27", 1, r"""[ "$($K -n default get pod "static-web-$W3" -o jsonpath='{.spec.containers[0].image}' 2>/dev/null)" = nginx:1.27 ]"""),
],
},
{
"id": 6, "domain": "ARCH", "title": "Certificates: expiry check and user CSR",
"task": """
Part A — On the control-plane node (`{SHELL_CP}`) determine the expiry date of the
**kube-apiserver** serving certificate (use `kubeadm certs check-expiration` or `openssl`).
Write the expiry line to `{WS}/q06/apiserver-expiry.txt` on your machine.
Part B — Onboard a new user `jane`:
1. Generate a private key and a CSR with CN=jane (openssl).
2. Create a Kubernetes `CertificateSigningRequest` named `jane`
(signer `kubernetes.io/kube-apiserver-client`, usage `client auth`).
3. Approve it so a certificate is issued.
4. Create a RoleBinding `jane-view` in namespace `default` giving user `jane`
the built-in ClusterRole `view`.
""",
"setup": r"""
rm -rf "$WS/q06"; mkdir -p "$WS/q06"
$K delete csr jane --ignore-not-found >/dev/null
$K -n default delete rolebinding jane-view --ignore-not-found >/dev/null
""",
"checks": [
C("apiserver-expiry.txt records the real expiry month and year", 2, r"""
[ -s "$WS/q06/apiserver-expiry.txt" ] || exit 1
exp=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -enddate' | sed 's/notAfter=//')
yr=$(echo "$exp" | awk '{print $4}'); mon=$(echo "$exp" | awk '{print $1}')
grep -q "$yr" "$WS/q06/apiserver-expiry.txt" && grep -qi "$mon" "$WS/q06/apiserver-expiry.txt" """),
C("CSR jane exists with signer kubernetes.io/kube-apiserver-client", 1, r"""[ "$($K get csr jane -o jsonpath='{.spec.signerName}' 2>/dev/null)" = kubernetes.io/kube-apiserver-client ]"""),
C("CSR jane approved and certificate issued", 2, r"""$K get csr jane -o jsonpath='{.status.conditions[*].type}' 2>/dev/null | grep -q Approved && [ -n "$($K get csr jane -o jsonpath='{.status.certificate}')" ]"""),
C("jane can list pods in default (view) but not delete them", 1, r"""[ "$($K auth can-i list pods -n default --as=jane 2>/dev/null || true)" = yes ] && [ "$($K auth can-i delete pods -n default --as=jane 2>/dev/null || true)" = no ]"""),
],
},
# ================================================================ WORK ====
{
"id": 7, "domain": "WORK", "title": "Rolling update & scaling",
"task": """
Deployment `web-frontend` in namespace `apps` runs `nginx:1.25` with 2 replicas.
- Update the image to `nginx:1.27`.
- Scale to **4** replicas.
- Configure the rolling update strategy: `maxSurge: 1`, `maxUnavailable: 0`.
- All 4 replicas must be available when you are done.
""",
"setup": NS + r"""
mkns apps
$K -n apps delete deploy web-frontend --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-frontend
namespace: apps
spec:
replicas: 2
selector:
matchLabels: {app: web-frontend}
template:
metadata:
labels: {app: web-frontend}
spec:
containers:
- name: nginx
image: nginx:1.25
resources:
requests: {cpu: 50m, memory: 32Mi}
EOF
""",
"checks": [
C("image is nginx:1.27", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null)" = nginx:1.27 ]"""),
C("replicas = 4", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.replicas}' 2>/dev/null)" = 4 ]"""),
C("strategy maxSurge=1 maxUnavailable=0", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.strategy.rollingUpdate.maxSurge}')" = 1 ] && [ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.strategy.rollingUpdate.maxUnavailable}')" = 0 ]"""),
C("all 4 replicas available", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.status.availableReplicas}' 2>/dev/null)" = 4 ]"""),
],
},
{
"id": 8, "domain": "WORK", "title": "Multi-container pod with shared volume",
"task": """
Create a pod `log-shipper` in namespace `apps` with two containers:
- `app` (image `busybox:1.36`): appends the current date to `/var/log/app.log` every 2 seconds.
- `shipper` (image `busybox:1.36`): runs `tail -f /var/log/app.log`.
Both containers share an `emptyDir` volume named `logs` mounted at `/var/log`.
`kubectl logs log-shipper -c shipper` must show the dates written by `app`.
""",
"setup": NS + r"""
mkns apps
$K -n apps delete pod log-shipper --ignore-not-found --wait=false >/dev/null
""",
"checks": [
C("pod log-shipper Running with containers app and shipper", 1, r"""
p="$K -n apps get pod log-shipper"
[ "$($p -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] || exit 1
n=$($p -o jsonpath='{.spec.containers[*].name}'); echo "$n" | grep -qw app && echo "$n" | grep -qw shipper"""),
C("volume logs is an emptyDir mounted at /var/log in both containers", 2, r"""
p="$K -n apps get pod log-shipper"
[ -n "$($p -o jsonpath='{.spec.volumes[?(@.name=="logs")].emptyDir}' 2>/dev/null)" ] || exit 1
[ "$($p -o jsonpath='{.spec.containers[?(@.name=="app")].volumeMounts[?(@.name=="logs")].mountPath}')" = /var/log ] || exit 1
[ "$($p -o jsonpath='{.spec.containers[?(@.name=="shipper")].volumeMounts[?(@.name=="logs")].mountPath}')" = /var/log ]"""),
C("shipper's logs show lines written by app", 1, r"""sleep 3; [ -n "$($K -n apps logs log-shipper -c shipper --tail=2 2>/dev/null)" ]"""),
],
},
{
"id": 9, "domain": "WORK", "title": "Scheduling with node affinity and resources",
"task": """
One node in the cluster is labelled `disktype=ssd`.
Create a Deployment `cache` in namespace `apps`, 2 replicas, image `redis:7-alpine`, that:
- uses **required** node affinity (`requiredDuringSchedulingIgnoredDuringExecution`)
so pods only run on nodes with `disktype=ssd` (do not use nodeSelector),
- sets resource requests `cpu: 100m`, `memory: 64Mi` and limits `cpu: 200m`, `memory: 128Mi`.
Both pods must be Running.
""",
"setup": NS + r"""
mkns apps
$K label node "$W3" disktype=ssd --overwrite >/dev/null
$K label node "$W1" disktype- >/dev/null 2>&1 || true
$K label node "$W2" disktype- >/dev/null 2>&1 || true
$K -n apps delete deploy cache --ignore-not-found >/dev/null
""",
"checks": [
C("deployment cache with 2 replicas", 1, r"""[ "$($K -n apps get deploy cache -o jsonpath='{.spec.replicas}' 2>/dev/null)" = 2 ]"""),
C("required node affinity set, no nodeSelector", 1, r"""d="$K -n apps get deploy cache"; [ -n "$($d -o jsonpath='{.spec.template.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution}' 2>/dev/null)" ] && [ -z "$($d -o jsonpath='{.spec.template.spec.nodeSelector}')" ]"""),
C("requests 100m/64Mi and limits 200m/128Mi", 1, r"""
c='{.spec.template.spec.containers[0].resources'; d="$K -n apps get deploy cache"
[ "$($d -o jsonpath="$c.requests.cpu}")" = 100m ] && [ "$($d -o jsonpath="$c.requests.memory}")" = 64Mi ] && [ "$($d -o jsonpath="$c.limits.cpu}")" = 200m ] && [ "$($d -o jsonpath="$c.limits.memory}")" = 128Mi ]"""),
C("both pods available and on the ssd node", 1, r"""
[ "$($K -n apps get deploy cache -o jsonpath='{.status.availableReplicas}')" = 2 ] || exit 1
for n in $($K -n apps get pod -l app=cache -o jsonpath='{.items[*].spec.nodeName}'); do [ "$n" = "$W3" ] || exit 1; done"""),
],
},
{
"id": 10, "domain": "WORK", "title": "ConfigMap and Secret consumption",
"task": """
In namespace `apps`:
1. Create ConfigMap `app-config` with keys `APP_MODE=production` and `LOG_LEVEL=debug`.
2. Create Secret `db-creds` with keys `username=admin` and `password=S3cr3t!`.
3. Create pod `config-pod` (image `busybox:1.36`, command `sleep 3600`) that:
- exposes `APP_MODE` as an env var sourced from the ConfigMap key,
- exposes **all** keys of `db-creds` as env vars (envFrom),
- mounts the ConfigMap as a volume at `/etc/config`.
""",
"setup": NS + r"""
mkns apps
$K -n apps delete pod config-pod --ignore-not-found --wait=false >/dev/null
$K -n apps delete cm app-config --ignore-not-found >/dev/null
$K -n apps delete secret db-creds --ignore-not-found >/dev/null
""",
"checks": [
C("ConfigMap app-config and Secret db-creds with correct values", 1, r"""[ "$($K -n apps get cm app-config -o jsonpath='{.data.LOG_LEVEL}' 2>/dev/null)" = debug ] && [ "$($K -n apps get secret db-creds -o jsonpath='{.data.password}' 2>/dev/null | base64 -d)" = 'S3cr3t!' ]"""),
C("APP_MODE injected via configMapKeyRef", 1, r"""[ "$($K -n apps get pod config-pod -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && [ -n "$($K -n apps get pod config-pod -o jsonpath='{.spec.containers[0].env[?(@.name=="APP_MODE")].valueFrom.configMapKeyRef}')" ] && $K -n apps exec config-pod -- env 2>/dev/null | grep -q '^APP_MODE=production$'"""),
C("all db-creds keys injected via envFrom", 1, r"""[ -n "$($K -n apps get pod config-pod -o jsonpath='{.spec.containers[0].envFrom[*].secretRef}' 2>/dev/null)" ] && $K -n apps exec config-pod -- env 2>/dev/null | grep -q '^password=S3cr3t!$'"""),
C("ConfigMap mounted at /etc/config", 1, r"""[ "$($K -n apps exec config-pod -- cat /etc/config/LOG_LEVEL 2>/dev/null)" = debug ]"""),
],
},
# ================================================================= NET ====
{
"id": 11, "domain": "NET", "title": "NetworkPolicy",
"task": """
Namespace `netpol` has deployments `frontend`, `backend` and `db`
(labels `app=frontend|backend|db`).
Create a NetworkPolicy `backend-policy` in `netpol` so that pods with `app=backend`
- accept ingress **only** from pods labelled `app=frontend` in the same namespace,
on TCP port **8080**,
- reject all other ingress.
Do not restrict egress. (The default kind CNI does not enforce policies;
you are graded on the policy spec.)
""",
"setup": NS + r"""
mkns netpol
for a in frontend backend db; do
$K -n netpol create deploy $a --image=nginx:1.25 --dry-run=client -o yaml | $K apply -f - >/dev/null
done
$K -n netpol delete netpol backend-policy --ignore-not-found >/dev/null
""",
"checks": [
C("policy selects app=backend, policyTypes Ingress only", 2, r"""n="$K -n netpol get netpol backend-policy"; [ "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" = backend ] && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress && ! $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress"""),
C("single ingress rule from podSelector app=frontend only", 2, r"""n="$K -n netpol get netpol backend-policy"; [ "$($n -o jsonpath='{.spec.ingress[0].from[0].podSelector.matchLabels.app}' 2>/dev/null)" = frontend ] && [ -z "$($n -o jsonpath='{.spec.ingress[1]}')" ] && [ -z "$($n -o jsonpath='{.spec.ingress[0].from[1]}')" ]"""),
C("allowed port is TCP 8080", 2, r"""n="$K -n netpol get netpol backend-policy"; [ "$($n -o jsonpath='{.spec.ingress[0].ports[0].port}' 2>/dev/null)" = 8080 ] && { p=$($n -o jsonpath='{.spec.ingress[0].ports[0].protocol}'); [ -z "$p" ] || [ "$p" = TCP ]; }"""),
],
},
{
"id": 12, "domain": "NET", "title": "NodePort Service",
"task": """
Deployment `shop` (namespace `svc`) serves HTTP on container port 80.
Expose it with a Service `shop-svc` of type **NodePort**:
port 80, targetPort 80, nodePort **30080**.
The Service must have ready endpoints.
""",
"setup": NS + r"""
mkns svc
$K -n svc create deploy shop --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n svc delete svc shop-svc --ignore-not-found >/dev/null
""",
"checks": [
C("Service shop-svc is NodePort, port 80 -> targetPort 80", 1, r"""s="$K -n svc get svc shop-svc"; [ "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" = NodePort ] && [ "$($s -o jsonpath='{.spec.ports[0].port}')" = 80 ] && [ "$($s -o jsonpath='{.spec.ports[0].targetPort}')" = 80 ]"""),
C("nodePort is 30080", 1, r"""[ "$($K -n svc get svc shop-svc -o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null)" = 30080 ]"""),
C("Service has endpoints", 2, r"""[ -n "$($K -n svc get endpointslices -l kubernetes.io/service-name=shop-svc -o jsonpath='{.items[*].endpoints[*].addresses[*]}' 2>/dev/null)" ]"""),
],
},
{
"id": 13, "domain": "NET", "title": "Ingress routing",
"task": """
Namespace `ingress-app` has Services `web` (port 80) and `api` (port 8080).
Create an Ingress `app-ingress` in `ingress-app`:
- `ingressClassName: nginx`
- host `shop.example.com`
- path `/api` (Prefix) -> service `api` port 8080
- path `/` (Prefix) -> service `web` port 80
(No ingress controller is installed; you are graded on the object.)
""",
"setup": NS + r"""
mkns ingress-app
$K -n ingress-app create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n ingress-app create deploy api --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n ingress-app expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n ingress-app expose deploy api --port=8080 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n ingress-app delete ingress app-ingress --ignore-not-found >/dev/null
""",
"checks": [
C("ingressClassName nginx and host shop.example.com", 1, r"""i="$K -n ingress-app get ingress app-ingress"; [ "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" = nginx ] && [ "$($i -o jsonpath='{.spec.rules[0].host}')" = shop.example.com ]"""),
C("/api (Prefix) -> api:8080", 2, r"""i="$K -n ingress-app get ingress app-ingress"; P='{.spec.rules[0].http.paths[?(@.path=="/api")]'; [ "$($i -o jsonpath="$P.backend.service.name}" 2>/dev/null)" = api ] && [ "$($i -o jsonpath="$P.backend.service.port.number}")" = 8080 ] && [ "$($i -o jsonpath="$P.pathType}")" = Prefix ]"""),
C("/ (Prefix) -> web:80", 2, r"""i="$K -n ingress-app get ingress app-ingress"; P='{.spec.rules[0].http.paths[?(@.path=="/")]'; [ "$($i -o jsonpath="$P.backend.service.name}" 2>/dev/null)" = web ] && [ "$($i -o jsonpath="$P.backend.service.port.number}")" = 80 ] && [ "$($i -o jsonpath="$P.pathType}")" = Prefix ]"""),
],
},
{
"id": 14, "domain": "NET", "title": "Cluster DNS",
"task": """
Service `hello` exists in namespace `dns-check`.
1. Run a pod `dns-test` (image `busybox:1.36`) in namespace `dns-check`.
2. From inside it, resolve the Service using its **fully-qualified** DNS name.
3. Save the FQDN you used (one line) to `{WS}/q14/svc-fqdn.txt`.
4. Save the full `nslookup` output to `{WS}/q14/nslookup.txt`.
""",
"setup": NS + r"""
mkns dns-check
rm -rf "$WS/q14"; mkdir -p "$WS/q14"
$K -n dns-check create deploy hello --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n dns-check expose deploy hello --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n dns-check delete pod dns-test --ignore-not-found --wait=false >/dev/null
""",
"checks": [
C("pod dns-test exists in dns-check", 1, r"""$K -n dns-check get pod dns-test >/dev/null 2>&1"""),
C("svc-fqdn.txt contains hello.dns-check.svc.cluster.local", 2, r"""grep -qx 'hello.dns-check.svc.cluster.local\.\?' "$WS/q14/svc-fqdn.txt" 2>/dev/null"""),
C("nslookup.txt contains the service ClusterIP", 1, r"""ip=$($K -n dns-check get svc hello -o jsonpath='{.spec.clusterIP}'); grep -q "$ip" "$WS/q14/nslookup.txt" 2>/dev/null"""),
],
},
# ================================================================ STOR ====
{
"id": 15, "domain": "STOR", "title": "PersistentVolume, claim and pod",
"task": """
1. Create a PersistentVolume `pv-data`: 1Gi, `hostPath` `/mnt/data`,
accessMode `ReadWriteOnce`, `storageClassName: manual`, reclaim policy `Retain`.
2. Create a PersistentVolumeClaim `pvc-data` in namespace `storage` requesting 500Mi
with `storageClassName: manual` so it binds to `pv-data`.
3. Create pod `data-pod` (image `nginx:1.25`) in `storage` mounting the claim at `/usr/share/data`.
""",
"setup": NS + r"""
mkns storage
$K -n storage delete pod data-pod --ignore-not-found --wait=false >/dev/null
$K -n storage delete pvc pvc-data --ignore-not-found >/dev/null
$K delete pv pv-data --ignore-not-found >/dev/null
""",
"checks": [
C("PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual", 2, r"""
pv="$K get pv pv-data"
[ "$($pv -o jsonpath='{.spec.capacity.storage}' 2>/dev/null)" = 1Gi ] && [ "$($pv -o jsonpath='{.spec.hostPath.path}')" = /mnt/data ] && [ "$($pv -o jsonpath='{.spec.persistentVolumeReclaimPolicy}')" = Retain ] && [ "$($pv -o jsonpath='{.spec.storageClassName}')" = manual ] && $pv -o jsonpath='{.spec.accessModes}' | grep -q ReadWriteOnce"""),
C("PVC pvc-data Bound to pv-data", 2, r"""[ "$($K -n storage get pvc pvc-data -o jsonpath='{.status.phase}' 2>/dev/null)" = Bound ] && [ "$($K -n storage get pvc pvc-data -o jsonpath='{.spec.volumeName}')" = pv-data ]"""),
C("data-pod Running with claim mounted at /usr/share/data", 2, r"""[ "$($K -n storage get pod data-pod -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && [ -n "$($K -n storage get pod data-pod -o jsonpath='{.spec.volumes[?(@.persistentVolumeClaim.claimName=="pvc-data")].name}')" ] && $K -n storage get pod data-pod -o jsonpath='{.spec.containers[0].volumeMounts[*].mountPath}' | grep -q /usr/share/data"""),
],
},
{
"id": 16, "domain": "STOR", "title": "Default StorageClass and dynamic provisioning",
"task": """
1. Create a StorageClass `fast-local`:
provisioner `rancher.io/local-path`, `volumeBindingMode: WaitForFirstConsumer`,
`reclaimPolicy: Delete`.
2. Make `fast-local` the **default** StorageClass and ensure the existing
`standard` class is no longer default.
3. Create PVC `fast-claim` (200Mi, RWO) in namespace `storage` **without** specifying a storageClassName.
4. Create pod `fast-pod` (image `nginx:1.25`) in `storage` that mounts `fast-claim` at `/data`.
The claim must end up Bound.
""",
"setup": NS + r"""
mkns storage
$K -n storage delete pod fast-pod --ignore-not-found --wait=false >/dev/null
$K -n storage delete pvc fast-claim --ignore-not-found >/dev/null
$K delete sc fast-local --ignore-not-found >/dev/null
$K annotate sc standard storageclass.kubernetes.io/is-default-class=true --overwrite >/dev/null
""",
"checks": [
C("StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", 1, r"""sc="$K get sc fast-local"; [ "$($sc -o jsonpath='{.provisioner}' 2>/dev/null)" = rancher.io/local-path ] && [ "$($sc -o jsonpath='{.volumeBindingMode}')" = WaitForFirstConsumer ] && [ "$($sc -o jsonpath='{.reclaimPolicy}')" = Delete ]"""),
C("fast-local is default and standard is not", 2, r"""[ "$($K get sc fast-local -o jsonpath='{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}' 2>/dev/null)" = true ] && [ "$($K get sc standard -o jsonpath='{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" != true ]"""),
C("fast-claim picked up fast-local and is Bound", 1, r"""[ "$($K -n storage get pvc fast-claim -o jsonpath='{.spec.storageClassName}' 2>/dev/null)" = fast-local ] && [ "$($K -n storage get pvc fast-claim -o jsonpath='{.status.phase}')" = Bound ]"""),
C("fast-pod Running with claim at /data", 1, r"""[ "$($K -n storage get pod fast-pod -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && $K -n storage get pod fast-pod -o jsonpath='{.spec.containers[0].volumeMounts[*].mountPath}' | grep -qw /data"""),
],
},
# ============================================================== TSHOOT ====
{
"id": 17, "domain": "TSHOOT", "breaks": "w2-kubelet", "title": "Node NotReady", "disruptive": True,
"task": """
Node `{W2}` is `NotReady`.
Investigate and fix it so the node returns to `Ready`. The fix must survive a
reboot of the node (i.e. the responsible service must be enabled).
Reach the node with: `{SHELL_W2}`
""",
"setup": r"""nx "$W2" 'systemctl disable kubelet >/dev/null 2>&1; systemctl stop kubelet'""",
"checks": [
C("node is Ready", 4, r"""[ "$($K get node "$W2" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null)" = True ]"""),
C("kubelet is active", 2, r"""[ "$(nx "$W2" 'systemctl is-active kubelet')" = active ]"""),
C("kubelet is enabled (survives reboot)", 2, r"""[ "$(nx "$W2" 'systemctl is-enabled kubelet')" = enabled ]"""),
],
},
{
"id": 18, "domain": "TSHOOT", "title": "Broken deployment and service",
"task": """
The `payments` application in namespace `troubleshoot` is down.
Users report that the Service `payments` returns no backends and the pods are not healthy.
Fix everything necessary so that:
- the `payments` Deployment has all replicas Available (the app is a plain nginx web server), and
- the `payments` Service has endpoints.
Do not delete the Deployment or the Service; fix them in place.
""",
"setup": NS + r"""
mkns troubleshoot
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments
namespace: troubleshoot
spec:
replicas: 2
selector:
matchLabels: {app: payments}
template:
metadata:
labels: {app: payments}
spec:
containers:
- name: payments
image: nginx:1.99-nope
ports: [{containerPort: 80}]
---
apiVersion: v1
kind: Service
metadata:
name: payments
namespace: troubleshoot
spec:
selector:
app: payment
ports:
- port: 80
targetPort: 8080
EOF
""",
"checks": [
C("all payments replicas available", 3, r"""d="$K -n troubleshoot get deploy payments"; r=$($d -o jsonpath='{.spec.replicas}' 2>/dev/null); a=$($d -o jsonpath='{.status.availableReplicas}'); [ -n "$a" ] && [ "$a" = "$r" ]"""),
C("Service payments has endpoints (selector fixed)", 2, r"""[ -n "$($K -n troubleshoot get endpointslices -l kubernetes.io/service-name=payments -o jsonpath='{.items[*].endpoints[*].addresses[*]}' 2>/dev/null)" ]"""),
C("Service targetPort matches container port 80", 2, r"""[ "$($K -n troubleshoot get svc payments -o jsonpath='{.spec.ports[0].targetPort}' 2>/dev/null)" = 80 ]"""),
],
},
{
"id": 19, "domain": "TSHOOT", "title": "CrashLoopBackOff diagnosis",
"task": """
Pod `crasher` in namespace `troubleshoot` keeps restarting.
1. Find out why. Save the pod's (current or previous) container logs to
`{WS}/q19/crasher.log`.
2. Fix the pod so it stays Running. The application must keep the same name,
namespace, image and command; it only needs the configuration it is complaining about
(use the value `db.internal` if a value is required).
""",
"setup": NS + r"""
mkns troubleshoot
rm -rf "$WS/q19"; mkdir -p "$WS/q19"
$K -n troubleshoot delete pod crasher --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata:
name: crasher
namespace: troubleshoot
spec:
nodeSelector:
kubernetes.io/hostname: $W3
containers:
- name: app
image: busybox:1.36
command: ["sh", "-c", "if [ -z \"\$DB_HOST\" ]; then echo 'FATAL: required environment variable DB_HOST is not set'; exit 1; fi; echo \"connected to \$DB_HOST\"; while true; do sleep 5; done"]
EOF
""",
"checks": [
C("crasher.log captured and contains the DB_HOST failure", 2, r"""grep -q DB_HOST "$WS/q19/crasher.log" 2>/dev/null"""),
C("pod crasher Running with DB_HOST set", 3, r"""[ "$($K -n troubleshoot get pod crasher -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && [ -n "$($K -n troubleshoot exec crasher -- sh -c 'echo $DB_HOST' 2>/dev/null)" ]"""),
C("image unchanged (busybox:1.36)", 1, r"""[ "$($K -n troubleshoot get pod crasher -o jsonpath='{.spec.containers[0].image}' 2>/dev/null)" = busybox:1.36 ]"""),
],
},
{
"id": 20, "domain": "TSHOOT", "breaks": "scheduler", "title": "Control plane component failure", "disruptive": True,
"task": """
New pods in the cluster are stuck in `Pending` and are never assigned to a node.
Find the failing control-plane component and repair it on the control-plane node
(`{SHELL_CP}`). Afterwards, newly created pods must be scheduled normally.
""",
"setup": r"""
nx "$CP" '
f=/etc/kubernetes/manifests/kube-scheduler.yaml
grep -q "^ *- kube-scheduler$" $f && sed -i "s|^\( *\)- kube-scheduler$|\1- kube-schedulerr|" $f
' || true
""",
"checks": [
C("kube-scheduler static pod manifest repaired", 3, r"""nx "$CP" 'grep -q "^ *- kube-scheduler$" /etc/kubernetes/manifests/kube-scheduler.yaml'"""),
C("new pods get scheduled", 5, r"""
$K -n default delete pod sched-probe --ignore-not-found --wait=true >/dev/null 2>&1
$K -n default run sched-probe --image=busybox:1.36 --restart=Never -- sleep 300 >/dev/null 2>&1 || exit 1
n=""; for i in $(seq 1 30); do n=$($K -n default get pod sched-probe -o jsonpath='{.spec.nodeName}' 2>/dev/null); [ -n "$n" ] && break; sleep 2; done
$K -n default delete pod sched-probe --wait=false >/dev/null 2>&1
[ -n "$n" ]"""),
],
},
]
# ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 101-122 (hard) in bank_hard
from tracks.cka import bank_extra, bank_hard, bank_sim_a, bank_sim_b
QUESTIONS = QUESTIONS + bank_extra.QUESTIONS + bank_hard.QUESTIONS + bank_sim_a.QUESTIONS + bank_sim_b.QUESTIONS
QUESTIONS.sort(key=lambda q: q["id"])
_ids = [q["id"] for q in QUESTIONS]
assert len(_ids) == len(set(_ids)), "duplicate question ids"
from tracks.cka.solutions import S as _SOL
for _q in QUESTIONS:
_q["solution"] = (_q.get("solution") or _SOL.get(_q["id"], "")).strip()
_q.setdefault("set", None)
_missing = [q["id"] for q in QUESTIONS if not q["solution"]]
assert not _missing, f"tasks without a solution: {_missing}"

906
tracks/cka/solutions.py Normal file
View File

@ -0,0 +1,906 @@
"""Reference solutions, keyed by task id. Rendered with the same %{param} / {WS} / {W1} substitution
as the task text. Shown only after a task has been graded (drills) or the session submitted (exams).
These are one correct way, written the way you'd type them under time pressure: imperative first,
YAML only where imperative can't express it."""
S = {}
# ================================================================= ARCH ====
S[1] = """
k -n rbac-app create sa deploy-bot
k -n rbac-app create role deploy-manager --verb=get,list,watch,create,update,patch,delete --resource=deployments.apps
k -n rbac-app create rolebinding deploy-bot-binding --role=deploy-manager --serviceaccount=rbac-app:deploy-bot
k auth can-i create deployments -n rbac-app --as=system:serviceaccount:rbac-app:deploy-bot # yes
k auth can-i delete pods -n rbac-app --as=system:serviceaccount:rbac-app:deploy-bot # no
"""
S[2] = """
{SHELL_CP}
ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 \\
--cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key \\
snapshot save /opt/cka/etcd-snapshot.db
etcdutl snapshot status /opt/cka/etcd-snapshot.db -w table
# If etcdctl is only inside the pod: run the same command via
# kubectl -n kube-system exec etcd-{CP} -- etcdctl ... snapshot save /var/lib/etcd/snap.db
# then on the node: mv /var/lib/etcd/snap.db /opt/cka/etcd-snapshot.db (the pod's /var/lib/etcd is the node's /var/lib/etcd)
"""
S[3] = """
mkdir -p {WS}/q03/overlays/prod && cd {WS}/q03/overlays/prod
cat > namespace.yaml <<EOF
apiVersion: v1
kind: Namespace
metadata: {name: store-prod}
EOF
cat > kustomization.yaml <<EOF
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: store-prod
resources: [../../base, namespace.yaml]
labels:
- pairs: {env: prod}
includeSelectors: false
replicas:
- name: store-api
count: 3
EOF
kubectl apply -k .
"""
S[4] = """
cat <<EOF | k apply -f -
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: backups.data.example.com}
spec:
group: data.example.com
scope: Namespaced
names: {kind: Backup, plural: backups, singular: backup, shortNames: [bk]}
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
schedule: {type: string}
target: {type: string}
EOF
cat <<EOF | k apply -f -
apiVersion: data.example.com/v1
kind: Backup
metadata: {name: nightly, namespace: default}
spec: {schedule: "0 2 * * *", target: "s3://backups/prod"}
EOF
k get bk
"""
S[5] = """
{SHELL_W3}
grep staticPodPath /var/lib/kubelet/config.yaml # /etc/kubernetes/manifests
cat > /etc/kubernetes/manifests/static-web.yaml <<EOF
apiVersion: v1
kind: Pod
metadata: {name: static-web, namespace: default}
spec:
containers: [{name: web, image: nginx:1.27}]
EOF
# kubelet picks it up within seconds; mirror pod is static-web-{W3}
"""
S[6] = """
# Part A
{SHELL_CP}
kubeadm certs check-expiration | grep '^apiserver ' > (copy the line into {WS}/q06/apiserver-expiry.txt)
# Part B
openssl genrsa -out jane.key 2048
openssl req -new -key jane.key -subj "/CN=jane" -out jane.csr
cat <<EOF | k apply -f -
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata: {name: jane}
spec:
request: $(base64 -w0 jane.csr)
signerName: kubernetes.io/kube-apiserver-client
usages: [client auth]
EOF
k certificate approve jane
k get csr jane -o jsonpath='{.status.certificate}' | base64 -d > jane.crt
k -n default create rolebinding jane-view --clusterrole=view --user=jane
"""
S[21] = """
k create ns %{ns}
helm install %{rel} {WS}/q21/chart -n %{ns} --set replicaCount=%{n}
helm upgrade %{rel} {WS}/q21/chart -n %{ns} --reuse-values --set service.type=NodePort
helm -n %{ns} history %{rel} # revision 2
"""
S[33] = """
cat <<EOF | k apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata: {name: %{cr}}
aggregationRule:
clusterRoleSelectors:
- matchLabels: {rbac.example.com/aggregate-to-%{cr}: "true"}
rules: []
EOF
k create clusterrole %{cr}-pods --verb=get,list,watch --resource=pods
k create clusterrole %{cr}-services --verb=get,list,watch --resource=services
k label clusterrole %{cr}-pods %{cr}-services rbac.example.com/aggregate-to-%{cr}=true
k create clusterrolebinding %{cr}-binding --clusterrole=%{cr} --user=%{user}
k get clusterrole %{cr} -o yaml # rules now populated by the controller
"""
S[34] = """
k explain %{kind}.spec --recursive # find the field, then:
k explain %{kind}.spec.tuning.%{field} # confirm the description
echo spec.tuning.%{field} > {WS}/q34/path.txt
cat <<EOF | k apply -f -
apiVersion: demo.io/v1
kind: %{kind}
metadata: {name: sample, namespace: default}
spec:
tuning: {%{field}: %{n}}
EOF
"""
S[40] = """
{SHELL_CP}
kubeadm token create --ttl %{ttl} --print-join-command
# copy the printed line into {WS}/q40/join.sh
kubeadm token list
"""
S[41] = """
k version # Server Version -> apiserver=
k get node %{node} -o jsonpath='{.status.nodeInfo.kubeletVersion}{"\\n"}{.status.nodeInfo.containerRuntimeVersion}{"\\n"}'
k -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].spec.containers[0].image}' # tag after the colon
"""
# --------------------------------------------------------------- hard ARCH
S[101] = """
k get clusterrolebinding -o wide | grep %{sa} # find the cluster-admin binding -> delete it
k delete clusterrolebinding %{sa}-legacy-admin
k -n %{ns} create role %{sa}-role --verb=get,list,watch,patch --resource=deployments.apps,replicasets.apps
k -n %{ns} patch role %{sa}-role --type=json -p='[{"op":"add","path":"/rules/-","value":{"apiGroups":[""],"resources":["pods/exec"],"verbs":["create"]}}]'
k -n %{ns} create rolebinding %{sa}-rb --role=%{sa}-role --serviceaccount=%{ns}:%{sa}
k create clusterrole %{sa}-nodes --verb=list --resource=nodes
k create clusterrolebinding %{sa}-crb --clusterrole=%{sa}-nodes --serviceaccount=%{ns}:%{sa}
k auth can-i --list -n %{ns} --as=system:serviceaccount:%{ns}:%{sa}
"""
S[102] = """
{SHELL_CP}
etcdutl snapshot restore /opt/cka/backup.db --data-dir /var/lib/etcd-restore
# point the etcd static pod at the new dir: the hostPath volume, NOT just --data-dir
vi /etc/kubernetes/manifests/etcd.yaml # volumes: - hostPath: path: /var/lib/etcd -> /var/lib/etcd-restore
# kubelet restarts etcd; apiserver reconnects in ~30-60s
watch crictl ps # etcd + kube-apiserver come back
kubectl -n default get cm %{cm} # restored
"""
S[103] = """
k get nodes -l tier=edge # -> the node
{SHELL_W3}
grep staticPodPath /var/lib/kubelet/config.yaml # NOT /etc/kubernetes/manifests here
cat > <staticPodPath>/%{pod}.yaml <<EOF
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}, labels: {role: edge}}
spec:
containers:
- name: web
image: nginx:1.27
resources: {requests: {cpu: 50m, memory: 32Mi}}
EOF
"""
S[104] = """
openssl genrsa -out %{user}.key 2048
openssl req -new -key %{user}.key -subj "/CN=%{user}/O=developers" -out %{user}.csr
k apply -f - <<EOF
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata: {name: %{user}}
spec: {request: $(base64 -w0 %{user}.csr), signerName: kubernetes.io/kube-apiserver-client, usages: [client auth]}
EOF
k certificate approve %{user}
k get csr %{user} -o jsonpath='{.status.certificate}' | base64 -d > %{user}.crt
k -n %{ns} create rolebinding %{user}-edit --clusterrole=edit --user=%{user}
# self-contained kubeconfig (--embed-certs)
KC={WS}/q104/%{user}.kubeconfig
k config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt
SERVER=$(k config view --minify -o jsonpath='{.clusters[0].cluster.server}')
kubectl --kubeconfig $KC config set-cluster lab --server=$SERVER --certificate-authority=ca.crt --embed-certs
kubectl --kubeconfig $KC config set-credentials %{user} --client-certificate=%{user}.crt --client-key=%{user}.key --embed-certs
kubectl --kubeconfig $KC config set-context %{user}@lab --cluster=lab --user=%{user} --namespace=%{ns}
kubectl --kubeconfig $KC config use-context %{user}@lab
kubectl --kubeconfig $KC get pods
"""
S[105] = """
mkdir -p {WS}/q105/overlays/prod && cd {WS}/q105/overlays/prod
cat > replicas.yaml <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: web}
spec: {replicas: %{rep}}
EOF
cat > envfrom.yaml <<EOF
- op: add
path: /spec/template/spec/containers/0/envFrom
value: [{configMapRef: {name: web-config}}]
EOF
cat > kustomization.yaml <<EOF
namespace: %{ns}
resources: [../../base]
labels:
- pairs: {tier: prod}
includeSelectors: false
configMapGenerator:
- name: web-config
literals: [LOG_LEVEL=%{lvl}]
patches:
- path: replicas.yaml
- path: envfrom.yaml
target: {kind: Deployment, name: web}
EOF
k create ns %{ns}; k apply -k . # kustomize rewrites web-config -> web-config-<hash> in the envFrom too
"""
S[106] = """
cat <<EOF | k apply -f -
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata: {name: $(echo %{kind} | tr A-Z a-z)s.deploy.example.com}
spec:
group: deploy.example.com
scope: Namespaced
names: {kind: %{kind}, plural: $(echo %{kind} | tr A-Z a-z)s, singular: $(echo %{kind} | tr A-Z a-z)}
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [replicas, image]
properties:
replicas: {type: integer, minimum: 1, maximum: 10}
environment: {type: string, enum: [prod, stage, dev], default: dev}
image: {type: string}
EOF
k apply -f - <<EOF
apiVersion: deploy.example.com/v1
kind: %{kind}
metadata: {name: good}
spec: {replicas: 3, image: nginx:1.27, environment: %{env}}
EOF
printf 'apiVersion: deploy.example.com/v1\\nkind: %{kind}\\nmetadata: {name: bad}\\nspec: {replicas: 0, image: x}\\n' | k apply -f - 2> {WS}/q106/rejected.txt
printf 'apiVersion: deploy.example.com/v1\\nkind: %{kind}\\nmetadata: {name: defaulted}\\nspec: {replicas: 2, image: nginx:1.27}\\n' | k apply -f -
"""
S[122] = """
{SHELL_CP}
kubeadm certs renew all
# static pods do NOT reload certs by themselves: bounce them
mv /etc/kubernetes/manifests/*.yaml /tmp/ && sleep 20 && mv /tmp/*.yaml /etc/kubernetes/manifests/
# admin.conf was regenerated by `renew all`; use it:
cp /etc/kubernetes/admin.conf ~/.kube/config
kubeadm certs check-expiration | grep '^apiserver ' # -> {WS}/q122/expiry.txt
"""
# ================================================================= WORK ====
S[7] = """
k -n apps set image deploy web-frontend nginx=nginx:1.27
k -n apps scale deploy web-frontend --replicas=4
k -n apps patch deploy web-frontend -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}'
k -n apps rollout status deploy web-frontend
"""
S[8] = """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: log-shipper, namespace: apps}
spec:
volumes: [{name: logs, emptyDir: {}}]
containers:
- name: app
image: busybox:1.36
command: [sh, -c, "while true; do date >> /var/log/app.log; sleep 2; done"]
volumeMounts: [{name: logs, mountPath: /var/log}]
- name: shipper
image: busybox:1.36
command: [sh, -c, "tail -f /var/log/app.log"]
volumeMounts: [{name: logs, mountPath: /var/log}]
EOF
"""
S[9] = """
k -n apps create deploy cache --image=redis:7-alpine --replicas=2 $do > cache.yaml
# edit: add under spec.template.spec
# affinity:
# nodeAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# nodeSelectorTerms:
# - matchExpressions: [{key: disktype, operator: In, values: [ssd]}]
# and under the container: resources: {requests: {cpu: 100m, memory: 64Mi}, limits: {cpu: 200m, memory: 128Mi}}
k apply -f cache.yaml
"""
S[10] = """
k -n apps create cm app-config --from-literal=APP_MODE=production --from-literal=LOG_LEVEL=debug
k -n apps create secret generic db-creds --from-literal=username=admin --from-literal='password=S3cr3t!'
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: config-pod, namespace: apps}
spec:
volumes: [{name: cfg, configMap: {name: app-config}}]
containers:
- name: app
image: busybox:1.36
command: [sleep, "3600"]
env: [{name: APP_MODE, valueFrom: {configMapKeyRef: {name: app-config, key: APP_MODE}}}]
envFrom: [{secretRef: {name: db-creds}}]
volumeMounts: [{name: cfg, mountPath: /etc/config}]
EOF
"""
S[23] = """
k -n kube-system edit deploy metrics-server # args: add - --kubelet-insecure-tls
k top nodes # ~30s later
k -n %{ns} autoscale deploy %{dep} --min=2 --max=%{max} --cpu-percent=%{cpu} --name=%{dep}
"""
S[24] = """
cat <<EOF | k apply -f -
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: {name: %{pc}}
value: %{val}
globalDefault: false
description: "%{pc} workloads"
EOF
k -n %{ns} patch deploy payments -p '{"spec":{"template":{"spec":{"priorityClassName":"%{pc}"}}}}'
k -n %{ns} rollout status deploy payments
"""
S[25] = """
k taint node {W1} %{key}=%{val}:NoSchedule
k -n %{ns} create deploy %{dep} --image=busybox:1.36 --replicas=2 $do -- sleep 3600 > d.yaml
# add under spec.template.spec:
# nodeSelector: {kubernetes.io/hostname: {W1}}
# tolerations: [{key: %{key}, operator: Equal, value: %{val}, effect: NoSchedule}]
k apply -f d.yaml
"""
S[26] = """
cat <<EOF | k apply -f -
apiVersion: apps/v1
kind: DaemonSet
metadata: {name: %{ds}, namespace: %{ns}}
spec:
selector: {matchLabels: {app: %{ds}}}
template:
metadata: {labels: {app: %{ds}}}
spec:
tolerations: [{key: node-role.kubernetes.io/control-plane, operator: Exists, effect: NoSchedule}]
containers:
- name: agent
image: busybox:1.36
command: [sleep, "3600"]
resources: {requests: {cpu: 10m, memory: 16Mi}}
EOF
"""
S[27] = """
k -n %{ns} create cj %{cj} --image=busybox:1.36 --schedule='*/%{every} * * * *' $do -- date > cj.yaml
# set: spec.concurrencyPolicy: Forbid, successfulJobsHistoryLimit: 3, failedJobsHistoryLimit: 1,
# spec.jobTemplate.spec.activeDeadlineSeconds: 30, ...template.spec.restartPolicy: Never
k apply -f cj.yaml
k -n %{ns} create job %{cj}-manual --from=cronjob/%{cj}
"""
S[28] = """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes: [{name: content, emptyDir: {}}]
initContainers:
- name: setup
image: busybox:1.36
command: [sh, -c, "echo '%{text}' > /work/index.html"]
volumeMounts: [{name: content, mountPath: /work}]
containers:
- name: web
image: nginx:1.25
volumeMounts: [{name: content, mountPath: /usr/share/nginx/html}]
EOF
"""
S[29] = """
k -n %{ns} create cm %{cm}-v2 --from-literal=mode=new
k -n %{ns} patch cm %{cm}-v2 -p '{"immutable":true}' # optional, keeps the pattern
k -n %{ns} set volume deploy %{dep} --add=false 2>/dev/null; \\
k -n %{ns} patch deploy %{dep} --type=json -p='[{"op":"replace","path":"/spec/template/spec/volumes/0/configMap/name","value":"%{cm}-v2"}]'
k -n %{ns} rollout status deploy %{dep}
"""
S[31] = """
k -n %{ns} create job %{job} --image=busybox:1.36 $do -- sh -c "echo done; sleep 2" > job.yaml
# spec: completions: %{c}, parallelism: %{p}, backoffLimit: 2 (restartPolicy Never is the default from create job)
k apply -f job.yaml && k -n %{ns} wait --for=condition=complete job/%{job} --timeout=120s
"""
S[32] = """
k -n %{ns} create quota %{ns}-quota --hard=pods=%{pods},requests.cpu=1,limits.memory=2Gi
cat <<EOF | k apply -f -
apiVersion: v1
kind: LimitRange
metadata: {name: %{ns}-defaults, namespace: %{ns}}
spec:
limits:
- type: Container
defaultRequest: {cpu: %{dcpu}, memory: 64Mi}
default: {cpu: 500m, memory: 256Mi}
EOF
k -n %{ns} create deploy worker --image=nginx:1.25 --replicas=2
"""
# --------------------------------------------------------------- hard WORK
S[107] = """
k -n %{ns} patch quota %{ns}-quota -p '{"spec":{"hard":{"pods":"3","requests.cpu":"400m"}}}' # keep a cap, make room
cat <<EOF | k apply -f -
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata: {name: %{dep}-prio}
value: 50000
EOF
k -n %{ns} create deploy %{dep} --image=nginx:1.27 --replicas=3 $do > d.yaml
# spec.template.spec:
# priorityClassName: %{dep}-prio
# tolerations: [{key: %{key}, operator: Equal, value: %{val}, effect: NoSchedule}]
# affinity:
# podAntiAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# - labelSelector: {matchLabels: {app: %{dep}}}
# topologyKey: kubernetes.io/hostname
# container resources: {requests: {cpu: 100m, memory: 64Mi}}
k apply -f d.yaml
"""
S[108] = """
k -n %{ns} rollout history deploy %{dep} # find the revision whose image is nginx:1.25
k -n %{ns} rollout history deploy %{dep} --revision=1 # confirm
k -n %{ns} rollout undo deploy %{dep} --to-revision=1
k -n %{ns} scale deploy %{dep} --replicas=3 && k -n %{ns} rollout status deploy %{dep}
k -n %{ns} rollout pause deploy %{dep}
k -n %{ns} set image deploy %{dep} nginx=nginx:1.27
k -n %{ns} set env deploy %{dep} RELEASE=next
k -n %{ns} get rs # no new RS while paused
"""
S[109] = """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
terminationGracePeriodSeconds: 5
volumes: [{name: logs, emptyDir: {}}]
initContainers:
- name: agent
image: busybox:1.36
restartPolicy: Always # <- native sidecar
command: [sh, -c, "while true; do date >> /var/log/agent.log; sleep 1; done"]
volumeMounts: [{name: logs, mountPath: /var/log}]
containers:
- name: app
image: nginx:1.27
volumeMounts: [{name: logs, mountPath: /var/log}]
readinessProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5}
livenessProbe: {tcpSocket: {port: 80}}
EOF
k -n %{ns} get pod %{pod} -o jsonpath='{.spec.initContainers[*].name} {.spec.containers[*].name}' | tr ' ' '\\n' | sort > {WS}/q109/containers.txt
k -n %{ns} get pod %{pod} -o jsonpath='{.status.podIP}' > {WS}/q109/ip.txt
"""
S[110] = """
k get pods -A -o json | jq -r '.items[] | select(.metadata.namespace!="kube-system") | select(.spec.containers[0].readinessProbe==null) | "\\(.metadata.namespace)/\\(.metadata.name)"' | sort > {WS}/q110/no-readiness.txt
k get pods -A --field-selector=status.phase=Running -o jsonpath='{range .items[*]}{.spec.nodeName}{"\\n"}{end}' | sort | uniq -c | awk '{print $2, $1}' > {WS}/q110/per-node.txt
k -n kube-system get pods -o jsonpath='{.items[*].spec.containers[*].image}' | tr ' ' '\\n' | sort -u > {WS}/q110/images.txt
# no jq? use: k get pods -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,RP:.spec.containers[0].readinessProbe --no-headers | awk '$3=="<none>" && $1!="kube-system"{print $1"/"$2}' | sort
"""
S[121] = """
k config use-context k8s-c2-AC
k config view --minify -o jsonpath='{..namespace}' # -> %{ns}, write it to {WS}/q121/ns.txt
k create deploy %{dep} --image=busybox:1.36 --replicas=2 -- sleep 3600
k label deploy %{dep} owner=ac && k patch deploy %{dep} -p '{"spec":{"template":{"metadata":{"labels":{"owner":"ac"}}}}}'
k expose deploy %{dep} --port=9000 --target-port=9000 --name=%{dep}
"""
# ================================================================== NET ====
S[11] = """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: backend-policy, namespace: netpol}
spec:
podSelector: {matchLabels: {app: backend}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: frontend}}}]
ports: [{protocol: TCP, port: 8080}]
EOF
"""
S[12] = """
k -n svc expose deploy shop --name=shop-svc --type=NodePort --port=80 --target-port=80 $do > svc.yaml
# set spec.ports[0].nodePort: 30080
k apply -f svc.yaml && k -n svc get endpointslices
"""
S[13] = """
k -n ingress-app create ingress app-ingress --class=nginx \\
--rule="shop.example.com/api*=api:8080" --rule="shop.example.com/*=web:80"
# `*` suffix -> pathType Prefix. Verify: k -n ingress-app get ingress app-ingress -o yaml
"""
S[14] = """
k -n dns-check run dns-test --image=busybox:1.36 -- sleep 3600
k -n dns-check exec dns-test -- nslookup hello.dns-check.svc.cluster.local | tee {WS}/q14/nslookup.txt
echo hello.dns-check.svc.cluster.local > {WS}/q14/svc-fqdn.txt
"""
S[22] = """
k apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml
cat <<EOF | k apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata: {name: %{gc}}
spec: {controllerName: example.com/gateway}
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: %{gw}, namespace: %{ns}}
spec:
gatewayClassName: %{gc}
listeners: [{name: http, protocol: HTTP, port: 80, hostname: %{host}.example.com}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: %{host}-route, namespace: %{ns}}
spec:
parentRefs: [{name: %{gw}}]
rules:
- matches: [{path: {type: PathPrefix, value: /api}}]
backendRefs: [{name: api, port: 8080}]
- backendRefs: [{name: web, port: 80}]
EOF
"""
S[30] = """
k -n %{ns} create deploy %{dep} --image=nginx:1.25 $do > d.yaml
# container: ports: [{name: %{pname}, containerPort: 80}]
# readinessProbe: {httpGet: {path: /, port: %{pname}}}
k apply -f d.yaml
k -n %{ns} expose deploy %{dep} --port=8080 --target-port=%{pname}
"""
S[42] = """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: %{app}-egress, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: %{app}}}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}
podSelector: {matchLabels: {k8s-app: kube-dns}}
ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
- to: [{ipBlock: {cidr: %{cidr}}}]
ports: [{protocol: TCP, port: 443}]
EOF
"""
S[43] = """
k -n %{ns} create svc clusterip %{sts} --clusterip=None --tcp=5432:5432 $do > h.yaml # fix selector: app: %{sts}
k apply -f h.yaml
k -n %{ns} create svc externalname legacy --external-name=%{ext}
k -n %{ns} exec %{sts}-0 -- nslookup %{sts}-0.%{sts} > {WS}/q43/pod0.txt
"""
# --------------------------------------------------------------- hard NET
S[111] = """
k -n %{ns} delete netpol temp-allow-all
k label ns %{from} team=%{from}
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: %{ns}}
spec: {podSelector: {}, policyTypes: [Ingress, Egress]}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: %{ns}}
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}}]
ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: api-ingress, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: api}}
policyTypes: [Ingress]
ingress:
- from: [{namespaceSelector: {matchLabels: {team: %{from}}}}]
ports: [{port: 8080}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: api-to-db, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: api}}
policyTypes: [Egress]
egress:
- to: [{podSelector: {matchLabels: {app: db}}}]
ports: [{port: 5432}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-ingress, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: api}}}]
ports: [{port: 5432}]
EOF
"""
S[112] = """
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=%{host}" -keyout tls.key -out tls.crt
k -n %{ns} create secret tls %{host}-tls --cert=tls.crt --key=tls.key
k -n %{ns} create ingress web --class=nginx --rule="%{host}/*=web:80,tls=%{host}-tls" \\
--annotation nginx.ingress.kubernetes.io/ssl-redirect=true
openssl x509 -in tls.crt -noout -fingerprint -sha256 | cut -d= -f2 > {WS}/q112/fp.txt
"""
S[113] = """
cat <<EOF | k apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata: {name: %{host}-gc}
spec: {controllerName: example.com/gw}
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: %{host}-gw, namespace: %{ns}}
spec:
gatewayClassName: %{host}-gc
listeners:
- {name: web, protocol: HTTP, port: 80, hostname: %{host}.example.com}
- {name: api, protocol: HTTP, port: 80, hostname: api.%{host}.example.com}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: %{host}-route, namespace: %{ns}}
spec:
parentRefs: [{name: %{host}-gw, sectionName: web}]
rules:
- matches: [{headers: [{name: X-Canary, value: always}]}]
backendRefs: [{name: v2, port: 80}]
- backendRefs:
- {name: v1, port: 80, weight: %{w2}}
- {name: v2, port: 80, weight: %{w}}
EOF
"""
S[114] = """
k -n kube-system get netpol && k -n kube-system delete netpol maintenance-lockdown
k -n kube-system get deploy coredns # 0/0 -> scale it
k -n kube-system scale deploy coredns --replicas=2
k -n kube-system edit cm coredns # forward . 10.255.255.254 -> forward . /etc/resolv.conf
k -n kube-system rollout restart deploy coredns
k run t --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default
"""
# ================================================================= STOR ====
S[15] = """
cat <<EOF | k apply -f -
apiVersion: v1
kind: PersistentVolume
metadata: {name: pv-data}
spec:
capacity: {storage: 1Gi}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: {path: /mnt/data}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: pvc-data, namespace: storage}
spec: {accessModes: [ReadWriteOnce], storageClassName: manual, resources: {requests: {storage: 500Mi}}}
---
apiVersion: v1
kind: Pod
metadata: {name: data-pod, namespace: storage}
spec:
volumes: [{name: d, persistentVolumeClaim: {claimName: pvc-data}}]
containers: [{name: web, image: nginx:1.25, volumeMounts: [{name: d, mountPath: /usr/share/data}]}]
EOF
"""
S[16] = """
cat <<EOF | k apply -f -
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: fast-local
annotations: {storageclass.kubernetes.io/is-default-class: "true"}
provisioner: rancher.io/local-path
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
EOF
k annotate sc standard storageclass.kubernetes.io/is-default-class-
cat <<EOF | k apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: fast-claim, namespace: storage}
spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 200Mi}}}
---
apiVersion: v1
kind: Pod
metadata: {name: fast-pod, namespace: storage}
spec:
volumes: [{name: d, persistentVolumeClaim: {claimName: fast-claim}}]
containers: [{name: web, image: nginx:1.25, volumeMounts: [{name: d, mountPath: /data}]}]
EOF
"""
S[35] = """
k get pv %{pv} -o yaml | grep -A5 claimRef # points at the deleted PVC -> remove it
k patch pv %{pv} --type=json -p='[{"op":"remove","path":"/spec/claimRef"}]'
k get pv %{pv} # Available
cat <<EOF | k apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec: {accessModes: [ReadWriteOnce], storageClassName: manual, volumeName: %{pv}, resources: {requests: {storage: 500Mi}}}
EOF
"""
S[36] = """
cat <<EOF | k apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 200Mi}}}
---
apiVersion: v1
kind: Pod
metadata: {name: writer, namespace: %{ns}}
spec:
restartPolicy: Never
volumes: [{name: d, persistentVolumeClaim: {claimName: %{pvc}}}]
containers: [{name: w, image: busybox:1.36, command: [sh, -c, "echo %{text} > /data/marker.txt"], volumeMounts: [{name: d, mountPath: /data}]}]
EOF
k -n %{ns} wait --for=jsonpath='{.status.phase}'=Succeeded pod/writer
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: reader, namespace: %{ns}}
spec:
volumes: [{name: d, persistentVolumeClaim: {claimName: %{pvc}}}]
containers: [{name: r, image: busybox:1.36, command: [sleep, "3600"], volumeMounts: [{name: d, mountPath: /data}]}]
EOF
"""
# --------------------------------------------------------------- hard STOR
S[115] = """
k get pv -l media --show-labels; k get node -l disk=nvme # lv-nvme is on {W2}
cat <<EOF | k apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: %{pvc}, namespace: %{ns}}
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-fast
selector: {matchLabels: {media: nvme}}
resources: {requests: {storage: 1Gi}}
---
apiVersion: v1
kind: Pod
metadata: {name: consumer, namespace: %{ns}}
spec:
volumes: [{name: d, persistentVolumeClaim: {claimName: %{pvc}}}]
containers: [{name: c, image: busybox:1.36, command: [sleep, "3600"], volumeMounts: [{name: d, mountPath: /data}]}]
EOF
# the PV's nodeAffinity pulls the pod to {W2} - no nodeSelector needed
"""
S[116] = """
k -n %{ns} apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: new-data, namespace: %{ns}}
spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 1Gi}}}
---
apiVersion: batch/v1
kind: Job
metadata: {name: migrate, namespace: %{ns}}
spec:
template:
spec:
restartPolicy: Never
volumes:
- {name: src, persistentVolumeClaim: {claimName: old-data}}
- {name: dst, persistentVolumeClaim: {claimName: new-data}}
containers:
- name: cp
image: busybox:1.36
command: [sh, -c, "cp -a /src/. /dst/ && ls /dst"]
volumeMounts: [{name: src, mountPath: /src}, {name: dst, mountPath: /dst}]
EOF
# RWO: the Job must land on the same node as `legacy` if the PV is node-bound; local-path is, so if it Pending -> add nodeName
k -n %{ns} wait --for=condition=complete job/migrate --timeout=120s
k -n %{ns} get pod legacy -o yaml > legacy.yaml # change claimName old-data -> new-data, strip status/uid/resourceVersion
k -n %{ns} delete pod legacy --now && k apply -f legacy.yaml
k get pv $(k -n %{ns} get pvc old-data -o jsonpath='{.spec.volumeName}') -o jsonpath='{.spec.persistentVolumeReclaimPolicy}' # Retain
k -n %{ns} delete pvc old-data
"""
# =============================================================== TSHOOT ====
S[17] = """
k get node {W2} # NotReady
{SHELL_W2}
systemctl status kubelet # inactive (dead), disabled
systemctl enable --now kubelet
journalctl -u kubelet -f # until "Successfully registered node"
"""
S[18] = """
k -n troubleshoot describe deploy payments | grep -i image # nginx:1.99-nope -> ErrImagePull
k -n troubleshoot set image deploy payments payments=nginx:1.27
k -n troubleshoot get svc payments -o yaml # selector app: payment (typo), targetPort 8080 (container is 80)
k -n troubleshoot patch svc payments -p '{"spec":{"selector":{"app":"payments"},"ports":[{"port":80,"targetPort":80}]}}'
k -n troubleshoot get endpointslices
"""
S[19] = """
k -n troubleshoot logs crasher --previous | tee {WS}/q19/crasher.log # FATAL: ... DB_HOST is not set
k -n troubleshoot get pod crasher -o yaml > crasher.yaml
# add under the container: env: [{name: DB_HOST, value: db.internal}] (strip status/uid/resourceVersion)
k -n troubleshoot delete pod crasher --now && k apply -f crasher.yaml
"""
S[20] = """
k -n kube-system get pods # kube-scheduler-... CrashLoopBackOff / Error
k -n kube-system logs kube-scheduler-{CP} # exec: "kube-schedulerr": not found
{SHELL_CP}
vi /etc/kubernetes/manifests/kube-scheduler.yaml # command: - kube-schedulerr -> - kube-scheduler
# kubelet restarts it; k -n kube-system get pods -w
"""
S[37] = """
k -n kube-system get pods -l k8s-app=kube-dns; k -n kube-system logs -l k8s-app=kube-dns --tail=20
k -n kube-system get cm coredns -o yaml # forward . 10.255.255.1 -> unreachable upstream
k -n kube-system edit cm coredns # forward . /etc/resolv.conf
k -n kube-system rollout restart deploy coredns
k run t --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default
"""
S[38] = """
k -n kube-system get pods # kube-controller-manager Error/CrashLoop
k -n kube-system logs kube-controller-manager-{CP} # flag error: lease duration ... invalid
{SHELL_CP}
vi /etc/kubernetes/manifests/kube-controller-manager.yaml # remove the bogus --leader-elect-lease-duration=1s line
k create deploy probe --image=busybox:1.36 -- sleep 60 && k get rs
"""
S[39] = """
{SHELL_W1}
systemctl status kubelet; journalctl -u kubelet -n 30 --no-pager # "open /etc/kubernetes/pki/ca-bundle.crt: no such file"
grep -n clientCAFile /var/lib/kubelet/config.yaml # -> /etc/kubernetes/pki/ca.crt
systemctl restart kubelet && systemctl enable kubelet
"""
# -------------------------------------------------------------- hard TSHOOT
S[117] = """
k get node {W2} # NotReady,SchedulingDisabled
{SHELL_W2}
systemctl status kubelet # disabled + dead
journalctl -u kubelet -n 30 --no-pager # after enable: "ca-wrong.crt: no such file"
sed -i 's|ca-wrong.crt|ca.crt|' /var/lib/kubelet/config.yaml
systemctl enable --now kubelet
exit
k uncordon {W2} # the third thing
k run t --image=busybox:1.36 --restart=Never --overrides='{"spec":{"nodeName":"{W2}"}}' -- sleep 60
"""
S[118] = """
k -n kube-system get pods # scheduler CrashLoop, controller-manager ImagePullBackOff
{SHELL_CP}
vi /etc/kubernetes/manifests/kube-scheduler.yaml # kube-schedulerr -> kube-scheduler
vi /etc/kubernetes/manifests/kube-controller-manager.yaml # image tag ...-broken -> remove suffix (match the others: crictl images)
k -n kube-system get pods -w
k create deploy probe --image=busybox:1.36 -- sleep 60 && k get deploy probe -w
"""
S[119] = """
k -n %{ns} get all; k -n %{ns} describe deploy %{dep}; k -n %{ns} describe rs -l app=%{dep} # quota: pods 1
k -n %{ns} patch quota tight -p '{"spec":{"hard":{"pods":"3"}}}'
k -n %{ns} set image deploy %{dep} web=nginx:1.27 # image tag typo
k -n %{ns} patch secret %{dep}-env -p '{"stringData":{"DATABASE_URL":"postgres://db"}}' # missing key referenced by env
k -n %{ns} rollout restart deploy %{dep} # picks up the secret key
k -n %{ns} patch svc %{dep} -p '{"spec":{"selector":{"app":"%{dep}"},"ports":[{"port":80,"targetPort":80}]}}'
"""
S[120] = """
k -n kube-system get pod -l component=kube-controller-manager -o yaml | grep cluster-cidr
k -n kube-system get pod -l component=kube-apiserver -o yaml | grep service-cluster-ip-range
k -n kube-system get svc kube-dns -o jsonpath='{.spec.clusterIP}'
k -n kube-system get cm kube-proxy -o yaml | grep '^ *mode:' # "" -> iptables
k get node %{node} -o jsonpath='{.spec.podCIDR}'
"""

563
tracks/ckad/questions.py Normal file
View File

@ -0,0 +1,563 @@
"""CKAD track. Starter bank - same format as the CKA banks, extend it the same way.
CKAD is a build-things exam: less cluster surgery, more "produce the right object, fast".
The clock is the adversary, so these are mostly single-object tasks graded exactingly.
"""
from tracks.cka.common import C, NS, ns_pick, name_pick
TRACK = {
"id": "ckad",
"name": "Certified Kubernetes Application Developer",
"minutes": 120,
"pass_pct": 66,
"domains": {
"DESIGN": "Application Design and Build (20%)",
"DEPLOY": "Application Deployment (20%)",
"OBS": "Application Observability and Maintenance (15%)",
"ENV": "Application Environment, Configuration and Security (25%)",
"NET": "Services and Networking (20%)",
},
"quota": {"DESIGN": 3, "DEPLOY": 3, "OBS": 2, "ENV": 2, "NET": 2},
"quota_hard": {"DESIGN": 3, "DEPLOY": 3, "OBS": 2, "ENV": 2, "NET": 2},
}
QUESTIONS = [
# ---------------------------------------------------------------- DESIGN ----
{
"id": 1, "domain": "DESIGN", "title": "Adapter sidecar",
"params": lambda r: {"ns": ns_pick(r, "app"), "pod": name_pick(r, "adapter")},
"task": """
Create Pod `%{pod}` in Namespace `%{ns}` with two containers sharing an `emptyDir` named `spool`
mounted at `/spool` in both:
- `writer` (busybox:1.36): every 2 seconds appends a line `metric 1` to `/spool/raw.txt`
- `adapter` (busybox:1.36): runs `tail -f /spool/raw.txt`
`kubectl logs %{pod} -c adapter` must show the writer's lines.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null""",
"checks": [
C("pod Running with containers writer and adapter", 2, r"""
p="$K -n %{ns} get pod %{pod}"
eq "$($p -o jsonpath='{.status.phase}' 2>/dev/null)" Running && $p -o jsonpath='{.spec.containers[*].name}' | grep -qw writer && $p -o jsonpath='{.spec.containers[*].name}' | grep -qw adapter"""),
C("emptyDir 'spool' mounted at /spool in both containers", 2, r"""
p="$K -n %{ns} get pod %{pod}"
[ -n "$($p -o jsonpath='{.spec.volumes[?(@.name=="spool")].emptyDir}')" ] \
&& eq "$($p -o jsonpath='{.spec.containers[?(@.name=="writer")].volumeMounts[?(@.name=="spool")].mountPath}')" /spool \
&& eq "$($p -o jsonpath='{.spec.containers[?(@.name=="adapter")].volumeMounts[?(@.name=="spool")].mountPath}')" /spool"""),
C("adapter logs show the writer's output", 2, r"""sleep 3; $K -n %{ns} logs %{pod} -c adapter --tail=3 2>/dev/null | grep -q 'metric 1'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes: [{name: spool, emptyDir: {}}]
containers:
- name: writer
image: busybox:1.36
command: [sh, -c, "while true; do echo 'metric 1' >> /spool/raw.txt; sleep 2; done"]
volumeMounts: [{name: spool, mountPath: /spool}]
- name: adapter
image: busybox:1.36
command: [sh, -c, "tail -f /spool/raw.txt"]
volumeMounts: [{name: spool, mountPath: /spool}]
EOF
k -n %{ns} logs %{pod} -c adapter
""",
},
{
"id": 2, "domain": "DESIGN", "title": "Init container waits for a dependency",
"params": lambda r: {"ns": ns_pick(r, "boot"), "pod": name_pick(r, "consumer")},
"task": """
Namespace `%{ns}` has a Service `backend` with no endpoints yet.
1. Create Pod `%{pod}` with an init container `wait-for-backend` (busybox:1.36) that loops until
the DNS name `backend` resolves, and a main container `app` (nginx:1.27).
It will sit in `Init:0/1`.
2. Then create Deployment `backend` (nginx:1.27, 1 replica, Pod label `app=backend`) so the
Service gets an endpoint and your Pod starts.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null
$K -n %{ns} delete deploy backend --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Service
metadata: {name: backend, namespace: %{ns}}
spec: {selector: {app: backend}, ports: [{port: 80}]}
EOF
""",
"checks": [
C("pod has an init container named wait-for-backend", 2, r"""[ -n "$(j -n %{ns} pod %{pod} '{.spec.initContainers[?(@.name=="wait-for-backend")].name}')" ]"""),
C("backend Deployment available with an endpoint on the Service", 3, r"""
eq "$(j -n %{ns} deploy backend '{.status.availableReplicas}')" 1 \
&& [ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=backend '{.items[*].endpoints[*].addresses[*]}')" ]"""),
C("pod is now Running", 3, r"""for i in $(seq 1 12); do [ "$(j -n %{ns} pod %{pod} '{.status.phase}')" = Running ] && exit 0; sleep 5; done; exit 1"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
initContainers:
- name: wait-for-backend
image: busybox:1.36
command: [sh, -c, "until nslookup backend; do echo waiting; sleep 2; done"]
containers: [{name: app, image: nginx:1.27}]
EOF
k -n %{ns} get pod %{pod} # Init:0/1
k -n %{ns} create deploy backend --image=nginx:1.27
k -n %{ns} patch deploy backend -p '{"spec":{"template":{"metadata":{"labels":{"app":"backend"}}}}}'
# A ClusterIP Service only resolves once it has endpoints - that is what unblocks the init container.
""",
},
{
"id": 3, "domain": "DESIGN", "title": "CronJob with deadlines",
"params": lambda r: {"ns": ns_pick(r, "batch"), "cj": name_pick(r, "digest"), "m": r.choice([3, 5, 7])},
"task": """
In `%{ns}` create CronJob `%{cj}`:
- schedule: every %{m} minutes
- image `busybox:1.36`, command prints `digest`
- a run that cannot start within 10 seconds of its schedule is skipped
- a run that takes longer than 20 seconds is killed
- keep 2 successful and 2 failed Jobs
- restartPolicy `Never`
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete cj %{cj} --ignore-not-found >/dev/null""",
"checks": [
C("schedule and history limits", 2, r"""
c="$K -n %{ns} get cj %{cj}"
eq "$($c -o jsonpath='{.spec.schedule}' 2>/dev/null)" "*/%{m} * * * *" && eq "$($c -o jsonpath='{.spec.successfulJobsHistoryLimit}')" 2 && eq "$($c -o jsonpath='{.spec.failedJobsHistoryLimit}')" 2"""),
C("startingDeadlineSeconds 10 and activeDeadlineSeconds 20", 3, r"""
c="$K -n %{ns} get cj %{cj}"
eq "$($c -o jsonpath='{.spec.startingDeadlineSeconds}' 2>/dev/null)" 10 && eq "$($c -o jsonpath='{.spec.jobTemplate.spec.activeDeadlineSeconds}')" 20"""),
C("restartPolicy Never", 1, r"""eq "$(j -n %{ns} cj %{cj} '{.spec.jobTemplate.spec.template.spec.restartPolicy}')" Never"""),
],
"solution": """
k -n %{ns} create cj %{cj} --image=busybox:1.36 --schedule='*/%{m} * * * *' $do -- sh -c "echo digest" > cj.yaml
# spec.startingDeadlineSeconds: 10 (skip a late start)
# spec.successfulJobsHistoryLimit: 2 / failedJobsHistoryLimit: 2
# spec.jobTemplate.spec.activeDeadlineSeconds: 20 (kill an overrunning run)
# spec.jobTemplate.spec.template.spec.restartPolicy: Never
k apply -f cj.yaml
# startingDeadlineSeconds lives on the CronJob, activeDeadlineSeconds on the Job template.
""",
},
# ---------------------------------------------------------------- DEPLOY ----
{
"id": 4, "domain": "DEPLOY", "title": "Roll forward, then back",
"params": lambda r: {"ns": ns_pick(r, "rel"), "dep": name_pick(r, "frontend")},
"task": """
Deployment `%{dep}` in `%{ns}` runs `nginx:1.25` with 3 replicas.
1. Update the image to `nginx:1.27` and record a change-cause annotation of `pick 1.27`.
2. Then update it to `nginx:9.99-nope`, which will never become ready.
3. Roll back so all 3 replicas are Available on `nginx:1.27` again.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.25 --replicas=3 >/dev/null
$K -n %{ns} rollout status deploy %{dep} --timeout=120s >/dev/null 2>&1
""",
"checks": [
C("running image is nginx:1.27 with 3 available", 4, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].image}')" nginx:1.27 && eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3"""),
C("rollout history has at least three revisions", 2, r"""[ "$($K -n %{ns} rollout history deploy %{dep} 2>/dev/null | grep -c '^[0-9]')" -ge 3 ]"""),
C("a change-cause annotation is recorded", 2, r"""j -n %{ns} deploy %{dep} '{.metadata.annotations.kubernetes\.io/change-cause}' | grep -q ."""),
],
"solution": """
k -n %{ns} set image deploy %{dep} nginx=nginx:1.27
k -n %{ns} annotate deploy %{dep} kubernetes.io/change-cause='pick 1.27' --overwrite
k -n %{ns} rollout status deploy %{dep}
k -n %{ns} set image deploy %{dep} nginx=nginx:9.99-nope
k -n %{ns} rollout status deploy %{dep} --timeout=30s # never completes
k -n %{ns} rollout history deploy %{dep}
k -n %{ns} rollout undo deploy %{dep}
k -n %{ns} rollout status deploy %{dep}
""",
},
{
"id": 5, "domain": "DEPLOY", "title": "Blue/green cutover",
"params": lambda r: {"ns": ns_pick(r, "bg"), "svc": name_pick(r, "shop")},
"task": """
Namespace `%{ns}` has Deployment `%{svc}-blue` (Pod labels `app=%{svc}`, `version=blue`) behind
Service `%{svc}`.
1. Create Deployment `%{svc}-green`: 2 replicas, image `nginx:1.27`, Pod labels `app=%{svc}` and
`version=green`.
2. When it is fully Available, switch Service `%{svc}` to serve green only.
3. Leave blue running untouched so the switch can be reversed.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{svc}-blue %{svc}-green --ignore-not-found >/dev/null
$K -n %{ns} delete svc %{svc} --ignore-not-found >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{svc}-blue, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{svc}, version: blue}}
template:
metadata: {labels: {app: %{svc}, version: blue}}
spec: {containers: [{name: web, image: nginx:1.25, ports: [{containerPort: 80}]}]}
---
apiVersion: v1
kind: Service
metadata: {name: %{svc}, namespace: %{ns}}
spec: {selector: {app: %{svc}, version: blue}, ports: [{port: 80, targetPort: 80}]}
EOF
""",
"checks": [
C("green Deployment available with the right labels", 3, r"""
eq "$(j -n %{ns} deploy %{svc}-green '{.status.availableReplicas}')" 2 \
&& eq "$(j -n %{ns} deploy %{svc}-green '{.spec.template.metadata.labels.version}')" green \
&& eq "$(j -n %{ns} deploy %{svc}-green '{.spec.template.metadata.labels.app}')" %{svc}"""),
C("Service selector now targets version=green", 3, r"""eq "$(j -n %{ns} svc %{svc} '{.spec.selector.version}')" green"""),
C("every Service endpoint belongs to a green Pod", 2, r"""
ips=$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{svc} '{.items[*].endpoints[*].addresses[*]}')
[ -n "$ips" ] || exit 1
for ip in $ips; do j -n %{ns} pod -l version=green '{.items[*].status.podIP}' | grep -qw "$ip" || exit 1; done"""),
C("blue is still running", 2, r"""eq "$(j -n %{ns} deploy %{svc}-blue '{.status.availableReplicas}')" 2"""),
],
"solution": """
k -n %{ns} get deploy %{svc}-blue -o yaml > green.yaml
# rename to %{svc}-green, change every version: blue -> green, image -> nginx:1.27,
# strip status/uid/resourceVersion/creationTimestamp
k apply -f green.yaml
k -n %{ns} rollout status deploy %{svc}-green
k -n %{ns} patch svc %{svc} -p '{"spec":{"selector":{"app":"%{svc}","version":"green"}}}'
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{svc}
# Rolling back is the same patch with version: blue - which is why blue stays up.
""",
},
{
"id": 6, "domain": "DEPLOY", "title": "Helm release lifecycle",
"params": lambda r: {"ns": ns_pick(r, "helm"), "rel": name_pick(r, "site")},
"task": """
A chart is at `{WS}/q6/chart`.
1. Install it into Namespace `%{ns}` (create it) as release `%{rel}` with `replicaCount=2`.
2. Upgrade the release to `replicaCount=4`.
3. Roll the release back so the Deployment is at 2 replicas again — without uninstalling it.
4. Write the release's current revision number to `{WS}/q6/revision.txt`.
""",
"setup": r"""
rm -rf "$WS/q6"; mkdir -p "$WS/q6/chart/templates"
cat > "$WS/q6/chart/Chart.yaml" <<'EOF'
apiVersion: v2
name: site
version: 0.1.0
EOF
cat > "$WS/q6/chart/values.yaml" <<'EOF'
replicaCount: 1
image: nginx:1.27
EOF
cat > "$WS/q6/chart/templates/deploy.yaml" <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: {{ .Release.Name }}-site}
spec:
replicas: {{ .Values.replicaCount }}
selector: {matchLabels: {app: {{ .Release.Name }}-site}}
template:
metadata: {labels: {app: {{ .Release.Name }}-site}}
spec: {containers: [{name: web, image: "{{ .Values.image }}"}]}
EOF
$K create ns %{ns} --dry-run=client -o yaml | $K apply -f - >/dev/null
helm -n %{ns} uninstall %{rel} >/dev/null 2>&1 || true
command -v helm >/dev/null || echo "warn: helm not installed"
""",
"checks": [
C("release deployed with at least three revisions of history", 3, r"""
helm -n %{ns} status %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;sys.exit(0 if json.load(sys.stdin)["info"]["status"]=="deployed" else 1)' || exit 1
[ "$(helm -n %{ns} history %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')" -ge 3 ]"""),
C("Deployment is back to 2 replicas", 3, r"""eq "$(j -n %{ns} deploy %{rel}-site '{.spec.replicas}')" 2"""),
C("revision.txt matches the release's current revision", 2, r"""
cur=$(helm -n %{ns} history %{rel} -o json 2>/dev/null | python3 -c 'import sys,json;print(max(r["revision"] for r in json.load(sys.stdin)))' 2>/dev/null)
[ -n "$cur" ] && eq "$(tr -dc '0-9' < "$WS/q6/revision.txt" 2>/dev/null)" "$cur" """),
],
"solution": """
k create ns %{ns}
helm -n %{ns} install %{rel} {WS}/q6/chart --set replicaCount=2
helm -n %{ns} upgrade %{rel} {WS}/q6/chart --set replicaCount=4
helm -n %{ns} history %{rel}
helm -n %{ns} rollback %{rel} 1 # creates a NEW revision identical to revision 1
helm -n %{ns} history %{rel} | grep '^[0-9]' | tail -1 | awk '{print $1}' > {WS}/q6/revision.txt
""",
},
# ------------------------------------------------------------------- OBS ----
{
"id": 7, "domain": "OBS", "title": "Three kinds of probe",
"params": lambda r: {"ns": ns_pick(r, "probe"), "dep": name_pick(r, "api")},
"task": """
Deployment `%{dep}` in `%{ns}` (nginx:1.27, 2 replicas) needs health checks:
- **startup**: HTTP GET `/` on port 80, `periodSeconds: 5`, allowed a total of 60 seconds to come up
- **readiness**: HTTP GET `/` on port 80, every 5 seconds, a single failure removes it from endpoints
- **liveness**: TCP on port 80, every 10 seconds, initial delay 15 seconds
All replicas must end up Ready.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.27 --replicas=2 >/dev/null
""",
"checks": [
C("startupProbe: httpGet / :80, period 5, failureThreshold 12", 3, r"""
c='{.spec.template.spec.containers[0].startupProbe'
eq "$(j -n %{ns} deploy %{dep} "$c.httpGet.path}")" / && eq "$(j -n %{ns} deploy %{dep} "$c.periodSeconds}")" 5 && eq "$(j -n %{ns} deploy %{dep} "$c.failureThreshold}")" 12"""),
C("readinessProbe: httpGet, period 5, failureThreshold 1", 3, r"""
c='{.spec.template.spec.containers[0].readinessProbe'
eq "$(j -n %{ns} deploy %{dep} "$c.httpGet.path}")" / && eq "$(j -n %{ns} deploy %{dep} "$c.periodSeconds}")" 5 && eq "$(j -n %{ns} deploy %{dep} "$c.failureThreshold}")" 1"""),
C("livenessProbe: tcpSocket 80, period 10, initialDelay 15", 3, r"""
c='{.spec.template.spec.containers[0].livenessProbe'
eq "$(j -n %{ns} deploy %{dep} "$c.tcpSocket.port}")" 80 && eq "$(j -n %{ns} deploy %{dep} "$c.periodSeconds}")" 10 && eq "$(j -n %{ns} deploy %{dep} "$c.initialDelaySeconds}")" 15"""),
C("all replicas Ready", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.readyReplicas}')" 2"""),
],
"solution": """
k -n %{ns} edit deploy %{dep} # add to the container:
# startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12}
# readinessProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 1}
# livenessProbe: {tcpSocket: {port: 80}, periodSeconds: 10, initialDelaySeconds: 15}
k -n %{ns} rollout status deploy %{dep}
# 12 x 5s = 60s. While a startupProbe runs, liveness and readiness are suspended, which is why
# it beats a long initialDelaySeconds on liveness for slow starters.
""",
},
{
"id": 8, "domain": "OBS", "title": "Diagnose and fix a broken Pod",
"params": lambda r: {"ns": ns_pick(r, "debug"), "pod": name_pick(r, "worker")},
"task": """
Pod `%{pod}` in `%{ns}` is not running.
1. Write the container's waiting **reason**, as reported in the Pod's status, to `{WS}/q8/reason.txt`.
2. Fix the Pod so it runs. Keep the name, Namespace and command — correct only what is wrong.
""",
"setup": NS + r"""
mkns %{ns}; rm -rf "$WS/q8"; mkdir -p "$WS/q8"
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
containers:
- name: worker
image: busybox:1.99-doesnotexist
command: [sh, -c, "echo working; sleep 86400"]
EOF
""",
"checks": [
C("reason.txt records the image pull failure", 3, r"""grep -Eqi 'ImagePull|ErrImagePull|ImagePullBackOff' "$WS/q8/reason.txt" 2>/dev/null"""),
C("pod Running with its original command", 4, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && j -n %{ns} pod %{pod} '{.spec.containers[0].command}' | grep -q sleep"""),
],
"solution": """
k -n %{ns} get pod %{pod}
k -n %{ns} get pod %{pod} -o jsonpath='{.status.containerStatuses[0].state.waiting.reason}'
# ImagePullBackOff -> {WS}/q8/reason.txt
k -n %{ns} describe pod %{pod} | tail -15 # "Failed to pull image ... not found"
k -n %{ns} get pod %{pod} -o yaml > fix.yaml # set image: busybox:1.36, strip status/uid/resourceVersion
k -n %{ns} delete pod %{pod} --now && k apply -f fix.yaml
# A Pod's image can't be changed in place, so delete + recreate is the fix (a Deployment could
# have been patched with `set image`).
""",
},
# ------------------------------------------------------------------- ENV ----
{
"id": 9, "domain": "ENV", "title": "SecurityContext and capabilities",
"params": lambda r: {"ns": ns_pick(r, "sec"), "pod": name_pick(r, "hardened"), "uid": r.choice([1001, 2000, 10001])},
"task": """
Create Pod `%{pod}` in `%{ns}` (image `busybox:1.36`, command `sleep 86400`) that:
- runs as user %{uid} and group %{uid}, and must not run as root,
- has a read-only root filesystem,
- drops **all** capabilities and adds back only `NET_BIND_SERVICE`,
- cannot gain new privileges.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null""",
"checks": [
C("runs as UID %{uid} with runAsNonRoot", 3, r"""
p="$K -n %{ns} get pod %{pod}"
u=$($p -o jsonpath='{.spec.securityContext.runAsUser}' 2>/dev/null); [ -z "$u" ] && u=$($p -o jsonpath='{.spec.containers[0].securityContext.runAsUser}')
eq "$u" %{uid} && $p -o jsonpath='{.spec.securityContext.runAsNonRoot}{.spec.containers[0].securityContext.runAsNonRoot}' | grep -q true"""),
C("readOnlyRootFilesystem and allowPrivilegeEscalation=false", 3, r"""
c='{.spec.containers[0].securityContext'
eq "$(j -n %{ns} pod %{pod} "$c.readOnlyRootFilesystem}")" true && eq "$(j -n %{ns} pod %{pod} "$c.allowPrivilegeEscalation}")" false"""),
C("capabilities drop ALL, add NET_BIND_SERVICE", 3, r"""
c='{.spec.containers[0].securityContext.capabilities'
j -n %{ns} pod %{pod} "$c.drop[*]}" | grep -q ALL && j -n %{ns} pod %{pod} "$c.add[*]}" | grep -q NET_BIND_SERVICE"""),
C("pod Running as that UID", 2, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && $K -n %{ns} exec %{pod} -- id 2>/dev/null | grep -q 'uid=%{uid}'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
securityContext: {runAsUser: %{uid}, runAsGroup: %{uid}, runAsNonRoot: true}
containers:
- name: c
image: busybox:1.36
command: [sleep, "86400"]
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities: {drop: [ALL], add: [NET_BIND_SERVICE]}
EOF
k -n %{ns} exec %{pod} -- id
# runAsUser/runAsGroup/runAsNonRoot exist at pod OR container level;
# capabilities, readOnlyRootFilesystem and allowPrivilegeEscalation are container-level only.
""",
},
{
"id": 10, "domain": "ENV", "title": "Projected volume: config, secret and token",
"params": lambda r: {"ns": ns_pick(r, "cfg"), "pod": name_pick(r, "combined")},
"task": """
In `%{ns}`:
1. Create ConfigMap `app-cfg` with `mode=live` and Secret `app-sec` with `apikey=xyz789`.
2. Create Pod `%{pod}` (busybox:1.36, `sleep 86400`) with a **single projected volume** named
`bundle` mounted at `/etc/bundle`, exposing:
- ConfigMap key `mode` as file `mode`,
- Secret key `apikey` as file `apikey`,
- a ServiceAccount token with audience `vault` and expiry 3600 seconds as file `token`.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null
$K -n %{ns} delete cm app-cfg --ignore-not-found >/dev/null; $K -n %{ns} delete secret app-sec --ignore-not-found >/dev/null
""",
"checks": [
C("ConfigMap and Secret hold the right values", 2, r"""
eq "$(j -n %{ns} cm app-cfg '{.data.mode}')" live && eq "$(j -n %{ns} secret app-sec '{.data.apikey}' | base64 -d)" xyz789"""),
C("one projected volume 'bundle' with all three sources", 3, r"""
s=$(j -n %{ns} pod %{pod} '{.spec.volumes[?(@.name=="bundle")].projected.sources}')
echo "$s" | grep -q configMap && echo "$s" | grep -q secret && echo "$s" | grep -q serviceAccountToken"""),
C("token source: audience vault, expiry 3600", 3, r"""
s=$(j -n %{ns} pod %{pod} '{.spec.volumes[?(@.name=="bundle")].projected.sources[*].serviceAccountToken}')
echo "$s" | grep -q '"audience":"vault"' && echo "$s" | grep -q '"expirationSeconds":3600'"""),
C("all three files readable at /etc/bundle", 3, r"""
eq "$($K -n %{ns} exec %{pod} -- cat /etc/bundle/mode 2>/dev/null)" live \
&& eq "$($K -n %{ns} exec %{pod} -- cat /etc/bundle/apikey 2>/dev/null)" xyz789 \
&& [ -n "$($K -n %{ns} exec %{pod} -- cat /etc/bundle/token 2>/dev/null)" ]"""),
],
"solution": """
k -n %{ns} create cm app-cfg --from-literal=mode=live
k -n %{ns} create secret generic app-sec --from-literal=apikey=xyz789
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
volumes:
- name: bundle
projected:
sources:
- configMap: {name: app-cfg, items: [{key: mode, path: mode}]}
- secret: {name: app-sec, items: [{key: apikey, path: apikey}]}
- serviceAccountToken: {audience: vault, expirationSeconds: 3600, path: token}
containers:
- name: c
image: busybox:1.36
command: [sleep, "86400"]
volumeMounts: [{name: bundle, mountPath: /etc/bundle}]
EOF
k -n %{ns} exec %{pod} -- ls /etc/bundle
""",
},
# ------------------------------------------------------------------- NET ----
{
"id": 11, "domain": "NET", "title": "Expose a Deployment two ways",
"params": lambda r: {"ns": ns_pick(r, "expose"), "dep": name_pick(r, "portal"), "np": r.choice([30310, 30320, 30330])},
"task": """
In `%{ns}` Deployment `%{dep}` (nginx:1.27, 2 replicas) serves on container port 80.
1. Expose it internally as ClusterIP Service `%{dep}`, port 8080 → 80.
2. Also expose it as NodePort Service `%{dep}-ext`, port 80 → 80, nodePort %{np}.
3. Create Ingress `%{dep}` (class `nginx`, host `%{dep}.example.com`, path `/` Prefix) pointing at
the **ClusterIP** Service on port 8080.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
$K -n %{ns} delete svc %{dep} %{dep}-ext --ignore-not-found >/dev/null
$K -n %{ns} delete ingress %{dep} --ignore-not-found >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.27 --replicas=2 --port=80 >/dev/null
""",
"checks": [
C("ClusterIP Service 8080 -> 80 with endpoints", 3, r"""
s="$K -n %{ns} get svc %{dep}"
eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" ClusterIP && eq "$($s -o jsonpath='{.spec.ports[0].port}')" 8080 && eq "$($s -o jsonpath='{.spec.ports[0].targetPort}')" 80 \
&& [ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{dep} '{.items[*].endpoints[*].addresses[*]}')" ]"""),
C("NodePort Service on %{np}", 2, r"""
s="$K -n %{ns} get svc %{dep}-ext"
eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" NodePort && eq "$($s -o jsonpath='{.spec.ports[0].nodePort}')" %{np}"""),
C("Ingress routes the host to the ClusterIP Service on 8080", 3, r"""
i="$K -n %{ns} get ingress %{dep}"
eq "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" nginx && eq "$($i -o jsonpath='{.spec.rules[0].host}')" %{dep}.example.com \
&& eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" %{dep} \
&& eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.port.number}')" 8080"""),
],
"solution": """
k -n %{ns} expose deploy %{dep} --port=8080 --target-port=80
k -n %{ns} expose deploy %{dep} --name=%{dep}-ext --type=NodePort --port=80 --target-port=80 $do > ext.yaml
# set spec.ports[0].nodePort: %{np}
k apply -f ext.yaml
k -n %{ns} create ingress %{dep} --class=nginx --rule="%{dep}.example.com/*=%{dep}:8080"
""",
},
{
"id": 12, "domain": "NET", "title": "Restrict a namespace with NetworkPolicy",
"params": lambda r: {"ns": ns_pick(r, "np"), "client": r.choice(["frontend", "gateway", "portal"])},
"task": """
Namespace `%{ns}` runs Pods labelled `app=db` and `app=%{client}`.
Create NetworkPolicy `db-allow` so `app=db` Pods accept ingress **only** from `app=%{client}`
Pods in the same Namespace on TCP 5432, and nothing else. Do not restrict egress.
(kindnet doesn't enforce policies here, so you are graded on the object.)
""",
"setup": NS + r"""
mkns %{ns}
for a in db %{client}; do
$K -n %{ns} create deploy $a --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} patch deploy $a -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"app\":\"$a\"}}}}}" >/dev/null
done
$K -n %{ns} delete netpol db-allow --ignore-not-found >/dev/null
""",
"checks": [
C("selects app=db, Ingress only", 2, r"""
n="$K -n %{ns} get netpol db-allow"
eq "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" db && eq "$($n -o jsonpath='{.spec.policyTypes}')" '["Ingress"]'"""),
C("exactly one rule: from app=%{client} on TCP 5432", 4, r"""
$K -n %{ns} get netpol db-allow -o json 2>/dev/null | python3 -c '
import sys,json
i=json.load(sys.stdin)["spec"].get("ingress",[])
ok=len(i)==1 and len(i[0].get("from",[]))==1 and i[0]["from"][0].get("podSelector",{}).get("matchLabels",{}).get("app")=="%{client}" \
and len(i[0].get("ports",[]))==1 and str(i[0]["ports"][0]["port"])=="5432"
sys.exit(0 if ok else 1)'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-allow, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: %{client}}}}]
ports: [{protocol: TCP, port: 5432}]
EOF
""",
},
]

557
tracks/cks/questions.py Normal file
View File

@ -0,0 +1,557 @@
"""CKS track. Starter bank.
Caveat worth knowing before you rely on this: several CKS objectives need infrastructure the
default kind cluster does not have - kindnet does not enforce NetworkPolicy, and there is no
Falco, AppArmor profile loader, gVisor runtime or image scanner installed. Tasks here either
stick to what a stock kubeadm/kind cluster can really do (PSA, RBAC, admission policies, audit
logging, seccomp, token hygiene) or are graded on the object rather than on enforcement, and say so.
"""
from tracks.cka.common import C, NS, ns_pick, name_pick
TRACK = {
"id": "cks",
"name": "Certified Kubernetes Security Specialist",
"minutes": 120,
"pass_pct": 67,
"domains": {
"SETUP": "Cluster Setup (15%)",
"HARDEN": "Cluster Hardening (15%)",
"SYS": "System Hardening (10%)",
"MICRO": "Minimize Microservice Vulnerabilities (20%)",
"SUPPLY": "Supply Chain Security (20%)",
"RUNTIME": "Monitoring, Logging and Runtime Security (20%)",
},
"quota": {"SETUP": 2, "HARDEN": 2, "SYS": 1, "MICRO": 2, "SUPPLY": 1, "RUNTIME": 1},
"quota_hard": {"SETUP": 2, "HARDEN": 2, "SYS": 1, "MICRO": 2, "SUPPLY": 1, "RUNTIME": 1},
}
QUESTIONS = [
# ----------------------------------------------------------------- SETUP ----
{
"id": 1, "domain": "SETUP", "title": "Default-deny, then allow what's needed",
"params": lambda r: {"ns": ns_pick(r, "zone")},
"task": """
Namespace `%{ns}` runs `app=web` (port 8080) and `app=db` (port 5432).
Create three NetworkPolicies:
1. `default-deny` — denies all ingress **and** egress for every Pod in the Namespace.
2. `allow-dns` — lets every Pod reach DNS (UDP and TCP 53) in `kube-system`.
3. `web-to-db` — lets `app=web` reach `app=db` on 5432, and lets `app=db` accept that ingress.
(kindnet does not enforce policies; you are graded on the objects.)
""",
"setup": NS + r"""
mkns %{ns}
for a in web db; do
$K -n %{ns} create deploy $a --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null
$K -n %{ns} patch deploy $a -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"app\":\"$a\"}}}}}" >/dev/null
done
$K -n %{ns} delete netpol --all >/dev/null 2>&1
""",
"checks": [
C("default-deny covers all pods, both directions, with no allow rules", 3, r"""
n="$K -n %{ns} get netpol default-deny"
eq "$($n -o jsonpath='{.spec.podSelector}' 2>/dev/null)" '{}' && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress \
&& $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress && [ -z "$($n -o jsonpath='{.spec.ingress}')" ] && [ -z "$($n -o jsonpath='{.spec.egress}')" ]"""),
C("allow-dns permits UDP and TCP 53 to kube-system", 3, r"""
$K -n %{ns} get netpol allow-dns -o json 2>/dev/null | python3 -c '
import sys,json
s=json.load(sys.stdin)["spec"]
ok=any({p.get("protocol") for p in r.get("ports",[]) if str(p.get("port"))=="53"}>={"UDP","TCP"}
and r.get("to") and all("kube-system" in json.dumps(t.get("namespaceSelector",{})) for t in r["to"])
for r in s.get("egress",[]))
sys.exit(0 if ok else 1)'"""),
C("web-to-db pairs web egress and db ingress on 5432", 4, r"""
$K -n %{ns} get netpol -o json 2>/dev/null | python3 -c '
import sys,json
eg=ing=False
for n in json.load(sys.stdin)["items"]:
s=n["spec"]; app=s["podSelector"].get("matchLabels",{}).get("app")
for r in s.get("egress",[]):
if app=="web" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="db" for t in r.get("to",[])) \
and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): eg=True
for r in s.get("ingress",[]):
if app=="db" and any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="web" for t in r.get("from",[])) \
and any(str(p.get("port"))=="5432" for p in r.get("ports",[])): ing=True
sys.exit(0 if eg and ing else 1)'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: %{ns}}
spec: {podSelector: {}, policyTypes: [Ingress, Egress]}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: allow-dns, namespace: %{ns}}
spec:
podSelector: {}
policyTypes: [Egress]
egress:
- to: [{namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: kube-system}}}]
ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: web-to-db, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: web}}
policyTypes: [Egress]
egress:
- to: [{podSelector: {matchLabels: {app: db}}}]
ports: [{port: 5432}]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-from-web, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: web}}}]
ports: [{port: 5432}]
EOF
# With a default deny in place, BOTH sides need a rule: egress on the client and ingress on the
# server. Forgetting one is the most common CKS mistake.
""",
},
{
"id": 2, "domain": "SETUP", "title": "Turn off apiserver profiling", "breaks": "apiserver-flag", "difficulty": "hard",
"task": """
An audit found the kube-apiserver's pprof profiling endpoints exposed.
1. Write the current value of the apiserver's `--profiling` setting (or `absent` if the flag is
not set) to `{WS}/q2/before.txt`.
2. Disable profiling on the kube-apiserver, persistently.
3. Confirm the API server comes back healthy and write its `--profiling` value afterwards to
`{WS}/q2/after.txt`.
""",
"setup": r"""
rm -rf "$WS/q2"; mkdir -p "$WS/q2"
nx "$CP" 'sed -i "/--profiling=/d" /etc/kubernetes/manifests/kube-apiserver.yaml' || true
""",
"checks": [
C("before.txt records the original state", 2, r"""grep -Eqi 'absent|true|not set|none' "$WS/q2/before.txt" 2>/dev/null"""),
C("the manifest now sets --profiling=false", 4, r"""nx "$CP" 'grep -q -- "--profiling=false" /etc/kubernetes/manifests/kube-apiserver.yaml'"""),
C("apiserver is healthy again", 4, r"""
for i in $(seq 1 20); do $K get --raw=/healthz >/dev/null 2>&1 && [ "$(j -n kube-system pod -l component=kube-apiserver '{.items[0].status.phase}')" = Running ] && exit 0; sleep 6; done; exit 1"""),
C("after.txt records false", 1, r"""grep -q 'false' "$WS/q2/after.txt" 2>/dev/null"""),
],
"solution": """
{SHELL_CP}
grep profiling /etc/kubernetes/manifests/kube-apiserver.yaml || echo absent # -> {WS}/q2/before.txt
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# under command:, add - --profiling=false
# the kubelet notices the manifest change and restarts the static pod - watch with:
crictl ps | grep apiserver
exit
k get --raw=/healthz
echo false > {WS}/q2/after.txt
# Editing the manifest is what makes it persistent; `kubectl edit` on the mirror pod would be lost.
# Mind the indentation: the flag goes in the container's command list, not under spec.
""",
},
# ---------------------------------------------------------------- HARDEN ----
{
"id": 3, "domain": "HARDEN", "title": "Replace a wildcard ClusterRole",
"params": lambda r: {"ns": ns_pick(r, "team"), "sa": name_pick(r, "runner")},
"task": """
ServiceAccount `%{sa}` in `%{ns}` is bound to a ClusterRole granting `*` on `*` cluster-wide.
Reduce it to exactly what it needs:
- read-only (`get`, `list`, `watch`) on `pods` and `pods/log` **in `%{ns}` only**.
Use Role `%{sa}-view` and RoleBinding `%{sa}-view`. Remove the over-privileged binding.
The ServiceAccount must have no other permissions anywhere.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create sa %{sa} --dry-run=client -o yaml | $K apply -f - >/dev/null
$K delete clusterrole %{sa}-god --ignore-not-found >/dev/null; $K delete clusterrolebinding %{sa}-god --ignore-not-found >/dev/null
$K -n %{ns} delete role,rolebinding --all >/dev/null 2>&1
$K create clusterrole %{sa}-god --verb='*' --resource='*.*' >/dev/null
$K create clusterrolebinding %{sa}-god --clusterrole=%{sa}-god --serviceaccount=%{ns}:%{sa} >/dev/null
""",
"checks": [
C("the wildcard binding is gone", 3, r"""! $K get clusterrolebinding %{sa}-god >/dev/null 2>&1"""),
C("can get/list/watch pods and pods/log in %{ns}", 3, r"""
for res in pods pods/log; do for v in get list watch; do
eq "$($K auth can-i $v $res -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes || exit 1
done; done"""),
C("nothing else: no delete pods, no secrets, nothing in other namespaces", 4, r"""
for t in "delete pods -n %{ns}" "get secrets -n %{ns}" "create pods -n %{ns}" "list pods -n default" "list nodes"; do
eq "$($K auth can-i $t --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null || true)" no || exit 1
done"""),
],
"solution": """
k get clusterrolebinding -o wide | grep %{sa}
k delete clusterrolebinding %{sa}-god
k -n %{ns} create role %{sa}-view --verb=get,list,watch --resource=pods,pods/log
k -n %{ns} create rolebinding %{sa}-view --role=%{sa}-view --serviceaccount=%{ns}:%{sa}
k auth can-i --list -n %{ns} --as=system:serviceaccount:%{ns}:%{sa}
# Deleting the ClusterRole itself is optional - the binding is what grants the access.
""",
},
{
"id": 4, "domain": "HARDEN", "title": "Stop handing out API tokens",
"params": lambda r: {"ns": ns_pick(r, "apps"), "dep": name_pick(r, "worker")},
"task": """
In Namespace `%{ns}`:
1. Make the **default** ServiceAccount stop mounting its API token into Pods automatically.
2. Deployment `%{dep}` genuinely needs API access: give it a dedicated ServiceAccount `%{dep}-sa`
that does mount its token, and make the Deployment use it.
3. Confirm: a new Pod using the default ServiceAccount has no token mounted, while `%{dep}`'s Pods do.
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} patch sa default -p '{"automountServiceAccountToken":null}' >/dev/null 2>&1 || true
$K -n %{ns} delete sa %{dep}-sa --ignore-not-found >/dev/null
$K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
$K -n %{ns} delete pod plain --ignore-not-found --wait=false >/dev/null
$K -n %{ns} create deploy %{dep} --image=nginx:1.27 >/dev/null
""",
"checks": [
C("default ServiceAccount has automountServiceAccountToken: false", 3, r"""eq "$(j -n %{ns} sa default '{.automountServiceAccountToken}')" false"""),
C("%{dep} uses %{dep}-sa, which does mount a token", 3, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.serviceAccountName}')" %{dep}-sa \
&& [ "$(j -n %{ns} sa %{dep}-sa '{.automountServiceAccountToken}')" != false ]"""),
C("%{dep} pods have a projected token mounted", 3, r"""
p=$(j -n %{ns} pod -l app=%{dep} '{.items[0].metadata.name}'); [ -n "$p" ] || exit 1
$K -n %{ns} exec "$p" -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""),
C("a pod on the default ServiceAccount has no token", 3, r"""
$K -n %{ns} delete pod plain --ignore-not-found --wait=true >/dev/null 2>&1
$K -n %{ns} run plain --image=busybox:1.36 --restart=Never -- sleep 120 >/dev/null 2>&1
for i in $(seq 1 15); do [ "$(j -n %{ns} pod plain '{.status.phase}')" = Running ] && break; sleep 3; done
r=1; $K -n %{ns} exec plain -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null || r=0
$K -n %{ns} delete pod plain --wait=false >/dev/null 2>&1; exit $r"""),
],
"solution": """
k -n %{ns} patch sa default -p '{"automountServiceAccountToken": false}'
k -n %{ns} create sa %{dep}-sa
k -n %{ns} set serviceaccount deploy %{dep} %{dep}-sa
k -n %{ns} rollout status deploy %{dep}
# The setting can also be put on the Pod spec (automountServiceAccountToken: false), which
# overrides the ServiceAccount - useful when only one workload should differ.
""",
},
# ------------------------------------------------------------------- SYS ----
{
"id": 5, "domain": "SYS", "title": "seccomp and a minimal container",
"params": lambda r: {"ns": ns_pick(r, "sys"), "pod": name_pick(r, "confined")},
"task": """
Create Pod `%{pod}` in `%{ns}` (image `nginx:1.27`) that:
- uses the **RuntimeDefault** seccomp profile at Pod level,
- runs as a non-root user (UID 101, nginx's own user) with `allowPrivilegeEscalation: false`,
- drops all capabilities and adds back only `NET_BIND_SERVICE` and `CHOWN`,
- mounts an `emptyDir` at `/var/cache/nginx` and one at `/var/run` so nginx can still write
while the root filesystem is read-only.
The Pod must reach Running.
""",
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null""",
"checks": [
C("seccompProfile RuntimeDefault at pod level", 3, r"""eq "$(j -n %{ns} pod %{pod} '{.spec.securityContext.seccompProfile.type}')" RuntimeDefault"""),
C("non-root UID 101, no privilege escalation, read-only root fs", 3, r"""
p="$K -n %{ns} get pod %{pod}"
u=$($p -o jsonpath='{.spec.securityContext.runAsUser}' 2>/dev/null); [ -z "$u" ] && u=$($p -o jsonpath='{.spec.containers[0].securityContext.runAsUser}')
eq "$u" 101 && eq "$($p -o jsonpath='{.spec.containers[0].securityContext.allowPrivilegeEscalation}')" false \
&& eq "$($p -o jsonpath='{.spec.containers[0].securityContext.readOnlyRootFilesystem}')" true"""),
C("capabilities drop ALL, add NET_BIND_SERVICE and CHOWN", 3, r"""
c='{.spec.containers[0].securityContext.capabilities'
j -n %{ns} pod %{pod} "$c.drop[*]}" | grep -q ALL && j -n %{ns} pod %{pod} "$c.add[*]}" | grep -q NET_BIND_SERVICE && j -n %{ns} pod %{pod} "$c.add[*]}" | grep -q CHOWN"""),
C("pod Running with both writable mounts", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running \
&& j -n %{ns} pod %{pod} '{.spec.containers[0].volumeMounts[*].mountPath}' | grep -q '/var/cache/nginx' \
&& j -n %{ns} pod %{pod} '{.spec.containers[0].volumeMounts[*].mountPath}' | grep -q '/var/run'"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
securityContext:
seccompProfile: {type: RuntimeDefault}
runAsUser: 101
runAsNonRoot: true
volumes:
- {name: cache, emptyDir: {}}
- {name: run, emptyDir: {}}
containers:
- name: web
image: nginx:1.27
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL], add: [NET_BIND_SERVICE, CHOWN]}
volumeMounts:
- {name: cache, mountPath: /var/cache/nginx}
- {name: run, mountPath: /var/run}
EOF
# readOnlyRootFilesystem breaks most images until you mount emptyDirs over the paths they write to;
# for nginx those are /var/cache/nginx and /var/run.
""",
},
# ----------------------------------------------------------------- MICRO ----
{
"id": 6, "domain": "MICRO", "title": "Enforce Pod Security restricted",
"params": lambda r: {"ns": ns_pick(r, "psa"), "dep": name_pick(r, "legacy")},
"task": """
Namespace `%{ns}` runs Deployment `%{dep}`, whose Pods run as root and request extra privileges.
1. Label the Namespace so the **restricted** Pod Security Standard is **enforced** (latest version),
and also **warn** and **audit** at restricted.
2. Fix `%{dep}` so its Pods are admitted under that standard, without changing its image
(`nginx:1.27`) or replica count.
All replicas must be Available afterwards.
""",
"setup": NS + r"""
mkns %{ns}
$K label ns %{ns} pod-security.kubernetes.io/enforce- pod-security.kubernetes.io/warn- pod-security.kubernetes.io/audit- >/dev/null 2>&1 || true
$K -n %{ns} delete deploy %{dep} --ignore-not-found --wait=true >/dev/null
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{dep}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{dep}}}
template:
metadata: {labels: {app: %{dep}}}
spec:
volumes: [{name: cache, emptyDir: {}}, {name: run, emptyDir: {}}]
containers:
- name: web
image: nginx:1.27
securityContext: {privileged: true}
volumeMounts: [{name: cache, mountPath: /var/cache/nginx}, {name: run, mountPath: /var/run}]
EOF
$K -n %{ns} rollout status deploy %{dep} --timeout=90s >/dev/null 2>&1
""",
"checks": [
C("namespace enforces, warns and audits restricted", 3, r"""
l=$(j ns %{ns} '{.metadata.labels}')
echo "$l" | grep -q '"pod-security.kubernetes.io/enforce":"restricted"' && echo "$l" | grep -q '"pod-security.kubernetes.io/warn":"restricted"' && echo "$l" | grep -q '"pod-security.kubernetes.io/audit":"restricted"'"""),
C("deployment no longer asks for privilege", 3, r"""
t=$(j -n %{ns} deploy %{dep} '{.spec.template.spec}')
! echo "$t" | grep -q '"privileged":true' && echo "$t" | grep -q '"allowPrivilegeEscalation":false' && echo "$t" | grep -q 'RuntimeDefault'"""),
C("it satisfies restricted: non-root, drop ALL", 3, r"""
t=$(j -n %{ns} deploy %{dep} '{.spec.template.spec}')
echo "$t" | grep -q '"runAsNonRoot":true' && echo "$t" | grep -q '"drop":\["ALL"\]'"""),
C("2 replicas Available under enforcement, image unchanged", 4, r"""
eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].image}')" nginx:1.27
for i in $(seq 1 12); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 5; done; exit 1"""),
],
"solution": """
k label ns %{ns} \\
pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=latest \\
pod-security.kubernetes.io/warn=restricted pod-security.kubernetes.io/audit=restricted --overwrite
k -n %{ns} rollout restart deploy %{dep} # now blocked, see the events for the exact reasons
k -n %{ns} get events --field-selector reason=FailedCreate
k -n %{ns} edit deploy %{dep} # pod template needs, at minimum:
# securityContext: {runAsNonRoot: true, runAsUser: 101, seccompProfile: {type: RuntimeDefault}}
# container securityContext: {allowPrivilegeEscalation: false, capabilities: {drop: [ALL]}}
# and remove privileged: true
k -n %{ns} rollout status deploy %{dep}
# restricted requires all four: runAsNonRoot, allowPrivilegeEscalation=false, drop ALL,
# and a RuntimeDefault (or Localhost) seccomp profile.
""",
},
{
"id": 7, "domain": "MICRO", "title": "Isolate a workload's ServiceAccount and namespace",
"params": lambda r: {"ns": ns_pick(r, "tenant"), "pod": name_pick(r, "isolated")},
"task": """
Create Pod `%{pod}` in Namespace `%{ns}` (image `busybox:1.36`, `sleep 86400`) that:
- uses ServiceAccount `%{pod}-sa` (create it),
- does **not** get an API token mounted, set on the **Pod**, not the ServiceAccount,
- runs with `hostNetwork`, `hostPID` and `hostIPC` all explicitly disabled,
- has both CPU and memory requests and limits set (any sane values).
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null
$K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null
""",
"checks": [
C("uses its own ServiceAccount", 2, r"""$K -n %{ns} get sa %{pod}-sa >/dev/null 2>&1 && eq "$(j -n %{ns} pod %{pod} '{.spec.serviceAccountName}')" %{pod}-sa"""),
C("token not mounted, set on the pod spec", 3, r"""
eq "$(j -n %{ns} pod %{pod} '{.spec.automountServiceAccountToken}')" false \
&& ! $K -n %{ns} exec %{pod} -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""),
C("host namespaces explicitly disabled", 3, r"""
p="$K -n %{ns} get pod %{pod}"
eq "$($p -o jsonpath='{.spec.hostNetwork}' 2>/dev/null)" false && eq "$($p -o jsonpath='{.spec.hostPID}')" false && eq "$($p -o jsonpath='{.spec.hostIPC}')" false"""),
C("requests and limits set for cpu and memory", 2, r"""
c='{.spec.containers[0].resources'
for f in requests.cpu requests.memory limits.cpu limits.memory; do [ -n "$(j -n %{ns} pod %{pod} "$c.$f}")" ] || exit 1; done"""),
],
"solution": """
k -n %{ns} create sa %{pod}-sa
cat <<EOF | k apply -f -
apiVersion: v1
kind: Pod
metadata: {name: %{pod}, namespace: %{ns}}
spec:
serviceAccountName: %{pod}-sa
automountServiceAccountToken: false
hostNetwork: false
hostPID: false
hostIPC: false
containers:
- name: c
image: busybox:1.36
command: [sleep, "86400"]
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: 200m, memory: 128Mi}
EOF
k -n %{ns} exec %{pod} -- ls /var/run/secrets/kubernetes.io/ 2>&1 || echo "no token, as intended"
""",
},
# ---------------------------------------------------------------- SUPPLY ----
{
"id": 8, "domain": "SUPPLY", "title": "Admission policy for allowed registries", "difficulty": "hard",
"params": lambda r: {"ns": ns_pick(r, "trusted"), "reg": r.choice(["registry.internal.io", "harbor.corp.io"])},
"task": """
Only images from `%{reg}/` may run in Namespace `%{ns}`.
Using **ValidatingAdmissionPolicy** (no third-party admission controller):
1. Create a `ValidatingAdmissionPolicy` named `allowed-registry` that denies any Pod whose
containers are not all from `%{reg}/`.
2. Bind it with a `ValidatingAdmissionPolicyBinding` named `allowed-registry` that enforces
(`validationActions: [Deny]`) on Namespace `%{ns}` only.
3. Prove it: creating a Pod from `nginx:1.27` in `%{ns}` must be rejected. Save the rejection
message to `{WS}/q8/denied.txt`.
""",
"setup": NS + r"""
mkns %{ns}
rm -rf "$WS/q8"; mkdir -p "$WS/q8"
$K label ns %{ns} registry-policy=enforced --overwrite >/dev/null
$K delete validatingadmissionpolicybinding allowed-registry --ignore-not-found >/dev/null 2>&1
$K delete validatingadmissionpolicy allowed-registry --ignore-not-found >/dev/null 2>&1
""",
"checks": [
C("ValidatingAdmissionPolicy allowed-registry exists and checks image prefixes", 3, r"""
v=$(j validatingadmissionpolicy allowed-registry '{.spec.validations[*].expression}' 2>/dev/null)
[ -n "$v" ] && echo "$v" | grep -q '%{reg}' && j validatingadmissionpolicy allowed-registry '{.spec.matchConstraints.resourceRules[*].resources[*]}' | grep -q pods"""),
C("binding enforces Deny and is scoped to %{ns}", 3, r"""
b="$K get validatingadmissionpolicybinding allowed-registry"
eq "$($b -o jsonpath='{.spec.policyName}' 2>/dev/null)" allowed-registry && $b -o jsonpath='{.spec.validationActions[*]}' | grep -q Deny \
&& $b -o jsonpath='{.spec.matchResources}' | grep -Eq '%{ns}|registry-policy'"""),
C("a disallowed image is actually rejected", 4, r"""
out=$($K -n %{ns} run vap-probe --image=nginx:1.27 --restart=Never 2>&1); rc=$?
$K -n %{ns} delete pod vap-probe --ignore-not-found --wait=false >/dev/null 2>&1
[ $rc -ne 0 ] && echo "$out" | grep -qi 'denied\|not allowed\|failed'"""),
C("denied.txt captures the rejection", 2, r"""grep -Eqi 'denied|not allowed|admission' "$WS/q8/denied.txt" 2>/dev/null"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: {name: allowed-registry}
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: [CREATE, UPDATE]
resources: ["pods"]
validations:
- expression: "object.spec.containers.all(c, c.image.startsWith('%{reg}/'))"
message: "images must come from %{reg}/"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: {name: allowed-registry}
spec:
policyName: allowed-registry
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels: {registry-policy: enforced}
EOF
k -n %{ns} run vap-probe --image=nginx:1.27 2>&1 | tee {WS}/q8/denied.txt # rejected
# CEL runs in-process in the apiserver - no webhook to deploy, no certificate to manage.
# Add initContainers to the expression too if the exam asks for every container.
""",
},
# --------------------------------------------------------------- RUNTIME ----
{
"id": 9, "domain": "RUNTIME", "title": "Turn on API audit logging", "breaks": "apiserver-flag", "difficulty": "hard",
"task": """
The cluster has no audit trail.
On the control-plane node:
1. Write an audit policy to `/etc/kubernetes/audit-policy.yaml` that logs Secret access at
`Metadata` level and everything else at `Metadata` too, but drops the noisy
`RequestReceived` stage.
2. Configure the kube-apiserver to use it, writing to `/var/log/kubernetes/audit.log`, keeping
7 days of logs and at most 5 rotated files.
3. Bring the API server back healthy, then generate an event (`kubectl get secrets -A`) and copy
the last audit line mentioning `secrets` to `{WS}/q9/audit-line.json`.
""",
"setup": r"""
rm -rf "$WS/q9"; mkdir -p "$WS/q9"
nx "$CP" 'sed -i "/--audit-/d" /etc/kubernetes/manifests/kube-apiserver.yaml; rm -f /etc/kubernetes/audit-policy.yaml' || true
""",
"checks": [
C("audit policy file exists and omits RequestReceived", 3, r"""
nx "$CP" 'test -f /etc/kubernetes/audit-policy.yaml' && nx "$CP" 'grep -q "omitStages" /etc/kubernetes/audit-policy.yaml' \
&& nx "$CP" 'grep -q "RequestReceived" /etc/kubernetes/audit-policy.yaml'"""),
C("apiserver flags: policy file, log path, maxage 7, maxbackup 5", 4, r"""
m=$(nx "$CP" 'cat /etc/kubernetes/manifests/kube-apiserver.yaml')
echo "$m" | grep -q -- '--audit-policy-file=/etc/kubernetes/audit-policy.yaml' \
&& echo "$m" | grep -q -- '--audit-log-path=/var/log/kubernetes/audit.log' \
&& echo "$m" | grep -q -- '--audit-log-maxage=7' && echo "$m" | grep -q -- '--audit-log-maxbackup=5'"""),
C("apiserver healthy and writing the log", 4, r"""
for i in $(seq 1 20); do $K get --raw=/healthz >/dev/null 2>&1 && break; sleep 6; done
$K get --raw=/healthz >/dev/null 2>&1 || exit 1
$K get secrets -A >/dev/null 2>&1
for i in $(seq 1 6); do nx "$CP" 'test -s /var/log/kubernetes/audit.log' && exit 0; sleep 5; done; exit 1"""),
C("audit-line.json is a real audit event about secrets", 3, r"""
python3 -c '
import json,sys
d=json.load(open("'"$WS"'/q9/audit-line.json"))
sys.exit(0 if d.get("kind")=="Event" and "secrets" in json.dumps(d) else 1)' 2>/dev/null"""),
],
"solution": """
{SHELL_CP}
mkdir -p /var/log/kubernetes
cat > /etc/kubernetes/audit-policy.yaml <<'EOF'
apiVersion: audit.k8s.io/v1
kind: Policy
omitStages:
- RequestReceived
rules:
- level: Metadata
resources:
- group: ""
resources: ["secrets"]
- level: Metadata
EOF
vi /etc/kubernetes/manifests/kube-apiserver.yaml
# command:
# - --audit-policy-file=/etc/kubernetes/audit-policy.yaml
# - --audit-log-path=/var/log/kubernetes/audit.log
# - --audit-log-maxage=7
# - --audit-log-maxbackup=5
# volumeMounts:
# - {name: audit-policy, mountPath: /etc/kubernetes/audit-policy.yaml, readOnly: true}
# - {name: audit-log, mountPath: /var/log/kubernetes}
# volumes:
# - {name: audit-policy, hostPath: {path: /etc/kubernetes/audit-policy.yaml, type: File}}
# - {name: audit-log, hostPath: {path: /var/log/kubernetes, type: DirectoryOrCreate}}
exit
k get secrets -A
# grab the last secrets line:
# grep secrets /var/log/kubernetes/audit.log | tail -1 -> {WS}/q9/audit-line.json
# Forgetting the two hostPath mounts is the usual reason the apiserver crashloops here:
# the flags point at paths that don't exist inside the container.
""",
},
]

468
web/index.html Normal file
View File

@ -0,0 +1,468 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Exam simulator</title>
<style>
:root {
--bg: #eceef1; --pane: #ffffff; --ink: #1d2126; --muted: #5f6b78; --line: #d5dae1;
--accent: #1f5fbf; --accent-ink: #ffffff; --warn: #b7791f; --danger: #b42318; --ok: #1e7f4f;
--flag: #d9a400; --term: #1a1b1e;
--sans: "IBM Plex Sans", "Segoe UI", system-ui, -apple-system, sans-serif;
--mono: "IBM Plex Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { font: 14px/1.5 var(--sans); color: var(--ink); background: var(--bg); }
button { font: inherit; cursor: pointer; }
a { color: var(--accent); }
code { font-family: var(--mono); font-size: .92em; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- top bar: exam name left, clock right, controls far right ---- */
header { height: 48px; display: flex; align-items: center; gap: 16px; padding: 0 14px;
background: #252a31; color: #e8ebef; }
header .name { font-weight: 600; }
header .mode { color: #a9b2bd; font-size: 13px; }
header .spacer { flex: 1; }
#clock { font-family: var(--mono); font-size: 20px; letter-spacing: .04em; font-variant-numeric: tabular-nums; }
#clock.warn { color: #f5c451; } #clock.danger { color: #ff7b72; } #clock.paused { color: #a9b2bd; }
.btn { border: 1px solid var(--line); background: var(--pane); color: var(--ink); border-radius: 4px; padding: 6px 12px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.ghost { background: transparent; color: #e8ebef; border-color: #4a525c; }
.btn:disabled { opacity: .5; cursor: default; }
/* ---- main split ---- */
main { height: calc(100% - 48px); display: grid; grid-template-columns: 56px 440px 6px 1fr; }
#qnav { background: #f6f7f9; border-right: 1px solid var(--line); overflow-y: auto; padding: 8px 0; }
#qnav button { display: block; width: 40px; height: 34px; margin: 4px auto; border: 1px solid var(--line); border-radius: 4px;
background: var(--pane); position: relative; font-variant-numeric: tabular-nums; }
#qnav button.current { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); font-weight: 600; }
#qnav button.flagged::after { content: ""; position: absolute; top: -4px; right: -4px; width: 10px; height: 10px;
background: var(--flag); border-radius: 50%; border: 2px solid #f6f7f9; }
#qnav button.pass { background: #e3f3ea; border-color: var(--ok); }
#qnav button.partial { background: #fbf1dc; border-color: var(--warn); }
#qnav button.fail { background: #f9e3e1; border-color: var(--danger); }
#task { background: var(--pane); display: flex; flex-direction: column; min-height: 0; }
#task .head { padding: 14px 18px 10px; border-bottom: 1px solid var(--line); }
#task .head .sub { color: var(--muted); font-size: 13px; }
#task h2 { margin: 2px 0 0; font-size: 17px; font-weight: 600; }
#task .body { flex: 1; overflow-y: auto; padding: 14px 18px; }
.prose p { margin: 0 0 10px; white-space: pre-wrap; }
.prose code { background: #eef2f7; padding: 1px 5px; border-radius: 3px; cursor: copy; border: 1px solid #dfe5ed; }
.prose code:hover { background: #dce6f5; }
.prose code.copied { background: #cfe9da; border-color: var(--ok); }
#task .foot { border-top: 1px solid var(--line); padding: 10px 18px; display: flex; gap: 8px; align-items: center; }
#task .foot .spacer { flex: 1; }
.btn.flag.on { background: #fff4c7; border-color: var(--flag); }
#checks { margin-top: 12px; border: 1px solid var(--line); border-radius: 4px; padding: 10px 12px; font-size: 13px; }
#checks div { padding: 2px 0; }
#solution { margin-top: 12px; border: 1px solid var(--line); border-radius: 4px; padding: 8px 12px; font-size: 13px; }
#solution summary { cursor: pointer; font-weight: 600; }
#solution pre { margin: 8px 0 0; white-space: pre-wrap; font: 12.5px/1.5 var(--mono); background: #f6f7f9; padding: 10px; border-radius: 4px; }
#checks .ok::before { content: "✓ "; color: var(--ok); } #checks .no::before { content: "✗ "; color: var(--danger); }
#notes { width: 100%; height: 120px; border: 0; border-top: 1px solid var(--line); resize: none; padding: 10px 18px;
font: 13px/1.45 var(--mono); background: #fbfbf6; color: var(--ink); }
#notes::placeholder { color: #9aa3ad; }
#handle { background: var(--line); cursor: col-resize; }
#termwrap { background: var(--term); position: relative; min-width: 0; }
#term { width: 100%; height: 100%; border: 0; display: block; }
#noterm { color: #cbd2da; padding: 32px; font-family: var(--mono); font-size: 13px; line-height: 1.7; max-width: 70ch; }
#noterm code { color: #f5c451; }
/* ---- overlays: start, confirm, results ---- */
.overlay { position: fixed; inset: 0; background: rgba(20,24,30,.55); display: flex; align-items: center; justify-content: center; }
.card { position: relative; background: var(--pane); border-radius: 6px; padding: 26px 28px; width: min(640px, 92vw); max-height: 88vh; overflow-y: auto; }
.card h1 { margin: 0 0 4px; font-size: 20px; }
.card .sub { color: var(--muted); margin-bottom: 18px; }
.card .row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 16px; }
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.opt { border: 1px solid var(--line); border-radius: 6px; padding: 14px; text-align: left; background: var(--pane); }
.opt:hover { border-color: var(--accent); }
.opt b { display: block; margin-bottom: 4px; }
.opt span { color: var(--muted); font-size: 13px; }
.picker { columns: 2; margin: 10px 0 0; font-size: 13px; }
.picker label { display: block; padding: 2px 0; }
pre.log { background: #f6f7f9; border: 1px solid var(--line); padding: 10px; font: 12px/1.5 var(--mono); max-height: 240px; overflow: auto; }
.score { font-size: 34px; font-family: var(--mono); }
.score small { font-size: 14px; color: var(--muted); font-family: var(--sans); }
.verdict { display: inline-block; padding: 2px 10px; border-radius: 4px; color: #fff; margin-left: 10px; font-size: 14px; vertical-align: middle; }
.verdict.pass { background: var(--ok); } .verdict.fail { background: var(--danger); }
.bar { display: grid; grid-template-columns: 70px 1fr 60px; gap: 10px; align-items: center; margin: 6px 0; font-size: 13px; }
.bar .track { height: 10px; background: #e6e9ee; border-radius: 5px; overflow: hidden; }
.bar .fill { height: 100%; background: var(--accent); }
.qres { margin-top: 16px; }
.qres details { border-top: 1px solid var(--line); padding: 6px 0; }
.qres summary { cursor: pointer; }
.qres summary .pts { float: right; font-family: var(--mono); }
.qres .t-pass summary .pts { color: var(--ok); } .qres .t-fail summary .pts { color: var(--danger); } .qres .t-partial summary .pts { color: var(--warn); }
.hidden { display: none !important; }
.pill { font-size:12px; padding:2px 8px; border-radius:10px; border:1px solid var(--line); color:var(--muted); }
.pill.sel { background:var(--accent); border-color:var(--accent); color:#fff; }
.pill.pick { cursor:pointer; }
.pill.pick:hover { border-color:var(--accent); color:var(--accent); }
.pill.done.pick:hover { color:var(--ok); }
.pill.done { background:#e3f3ea; border-color:var(--ok); color:var(--ok); }
.hardtag { font-size:11px; background:var(--danger); color:#fff; border-radius:3px; padding:1px 5px; margin-left:6px; vertical-align:middle; }
@media (prefers-reduced-motion: no-preference) { .prose code { transition: background .15s; } }
@media (max-width: 900px) { main { grid-template-columns: 56px 1fr; } #handle, #termwrap { display: none; } }
</style>
</head>
<body>
<header>
<span class="name" id="trackName">Exam simulator</span>
<span class="mode" id="modeLabel"></span>
<span class="spacer"></span>
<span id="clock">--:--:--</span>
<button class="btn ghost" id="btnPause">Pause</button>
<button class="btn ghost" id="btnHistory">History</button>
<button class="btn danger" id="btnEnd">Submit exam</button>
</header>
<main>
<nav id="qnav" aria-label="Questions"></nav>
<section id="task">
<div class="head">
<div class="sub" id="qMeta"></div>
<h2 id="qTitle"></h2>
</div>
<div class="body">
<div class="prose" id="qBody"></div>
<div id="checks" class="hidden"></div>
<details id="solution" class="hidden"><summary>Reference solution</summary><pre id="solutionBody"></pre></details>
</div>
<div class="foot">
<button class="btn" id="btnPrev">Previous</button>
<button class="btn" id="btnNext">Next</button>
<span class="spacer"></span>
<button class="btn" id="btnCheck">Check this task</button>
<button class="btn flag" id="btnFlag">Flag for review</button>
</div>
<textarea id="notes" placeholder="Notepad — kept with the attempt, not graded."></textarea>
</section>
<div id="handle" role="separator" aria-orientation="vertical"></div>
<section id="termwrap"></section>
</main>
<!-- start / lobby -->
<div class="overlay" id="lobby">
<div class="card">
<h1 id="lobbyTitle">Exam simulator</h1>
<div class="sub" id="lobbySub"></div>
<div id="ver" style="position:absolute;right:28px;top:30px;color:var(--muted);font-size:12px"></div>
<div id="trackPick" style="display:flex;gap:6px;margin:0 0 14px"></div>
<div style="color:var(--muted);font-size:12px;margin:-8px 0 4px">Drill a domain (✓ = drilled at least once):</div>
<div id="coverage" style="display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap"></div>
<label style="display:block;margin-bottom:14px"><input type="checkbox" id="hardMode"> <b>Hard mode</b>
<span style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks, exam contexts.</span></label>
<div class="opts" id="lobbyOpts">
<button class="opt" id="optStudy"><b>Study mode</b><span>5 tasks, no clock, reference solutions visible from the start. Nothing scored or recorded.</span></button>
<button class="opt" id="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button>
<button class="opt" id="optAll"><b>Drill the whole bank</b><span id="allDesc"></span></button>
<button class="opt" id="optDrill"><b>Drill: weak / pick</b><span>Tasks you're weak on, or choose your own. Feedback per task while you work.</span></button>
<button class="opt" id="optSimA"><b>Simulator Set A</b><span id="simADesc"></span></button>
<button class="opt" id="optSimB"><b>Simulator Set B</b><span id="simBDesc"></span></button>
<button class="opt" id="optExam" style="grid-column:1/3"><b>Full exam</b><span id="examDesc"></span></button>
</div>
<div id="gate" class="hidden" style="color:var(--warn);margin-top:10px;font-size:13px"></div>
<div id="drillPick" class="hidden">
<div class="sub" style="margin-top:14px">Pick tasks to drill. Pre-selected: weak or never attempted.</div>
<div class="picker" id="picker"></div>
<div class="row"><button class="btn primary" id="btnStartDrill">Start drill</button><button class="btn" id="btnBackDrill">Back</button></div>
</div>
<div id="seeding" class="hidden">
<div class="sub">Seeding scenarios on the cluster…</div>
<pre class="log" id="seedLog"></pre>
</div>
<div id="lobbyErr" class="hidden" style="color:var(--danger);margin-top:12px;white-space:pre-wrap"></div>
</div>
</div>
<!-- confirm submit -->
<div class="overlay hidden" id="confirm">
<div class="card">
<h1>Submit and end the session?</h1>
<div class="sub">Every task is graded against the cluster as it is right now. You can't return after submitting.</div>
<div id="confirmFlags"></div>
<div class="row"><button class="btn danger" id="btnConfirmEnd">Submit</button><button class="btn" id="btnCancelEnd">Keep working</button></div>
</div>
</div>
<!-- results -->
<div class="overlay hidden" id="results">
<div class="card">
<h1>Results</h1>
<div class="sub" id="resSub"></div>
<div><span class="score" id="resScore"></span><span class="verdict" id="resVerdict"></span></div>
<div id="resDomains" style="margin-top:14px"></div>
<div class="qres" id="resQuestions"></div>
<div class="row">
<button class="btn primary" id="btnDrillMissed">Drill what I missed</button>
<button class="btn" id="btnNewExam">New full exam</button>
<button class="btn" id="btnCloseRes">Review tasks</button>
</div>
</div>
</div>
<!-- history -->
<div class="overlay hidden" id="histOv">
<div class="card">
<h1>Attempt history</h1>
<div class="sub" id="histSub"></div>
<div id="histBody"></div>
<div class="row"><button class="btn" id="btnCloseHist">Close</button></div>
</div>
</div>
<script>
(() => {
const $ = s => document.querySelector(s);
let S = null, cur = null, skew = 0, clockT = null, autoSubmitted = false;
const api = (p, body) => fetch(p, body ? {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)} : {})
.then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j; });
const fmt = s => { s = Math.max(0, Math.floor(s)); return [s/3600, s%3600/60, s%60].map(n => String(Math.floor(n)).padStart(2,'0')).join(':'); };
const esc = s => s.replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
// minimal markdown: paragraphs, **bold**, `code` (click to copy). Lists stay as literal lines.
function render(md) {
return md.split(/\n\s*\n/).map(p => '<p>' + esc(p).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
.replace(/`([^`]+)`/g, (m, c) => `<code title="click to copy">${c}</code>`) + '</p>').join('');
}
function qResultClass(q) {
const r = S.session && S.session.results[q.id];
if (!r) return '';
return r.pass ? 'pass' : (r.earned ? 'partial' : 'fail');
}
function paint() {
const sess = S.session, live = sess && !sess.ended_at;
$('#trackName').textContent = S.track.name;
$('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
if (live && sess.untimed) $('#btnEnd').textContent = 'End study session';
$('#btnPause').classList.toggle('hidden', !live || sess.untimed);
if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause';
$('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill'));
$('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden')));
// question nav
const nav = $('#qnav'); nav.innerHTML = '';
S.questions.forEach(q => {
const b = document.createElement('button'); b.textContent = q.id;
b.title = `${S.questions.indexOf(q) + 1} of ${S.questions.length} — Q${q.id}: ${q.title}`;
b.className = [q.id === cur ? 'current' : '', sess && sess.flags.includes(q.id) ? 'flagged' : '',
(sess && (sess.mode === 'drill' || sess.ended_at)) ? qResultClass(q) : ''].join(' ');
b.onclick = () => { cur = q.id; paint(); };
nav.appendChild(b);
});
if (!S.questions.find(q => q.id === cur)) cur = S.questions[0] && S.questions[0].id;
const q = S.questions.find(q => q.id === cur);
if (q) {
const pos = S.questions.indexOf(q) + 1;
$('#qMeta').textContent = `Question ${pos} of ${S.questions.length} · Q${q.id} · ${S.track.domains[q.domain]} · ${q.weight} points`;
$('#qTitle').innerHTML = esc(q.title) + (q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : '');
$('#qBody').innerHTML = render(q.task);
$('#qBody').querySelectorAll('code').forEach(c => c.onclick = () => {
navigator.clipboard.writeText(c.textContent).then(() => { c.classList.add('copied'); setTimeout(() => c.classList.remove('copied'), 700); });
});
$('#btnFlag').classList.toggle('on', !!(sess && sess.flags.includes(q.id)));
$('#btnFlag').textContent = sess && sess.flags.includes(q.id) ? 'Flagged' : 'Flag for review';
const r = sess && sess.results[q.id], show = r && (sess.mode === 'drill' || sess.ended_at);
$('#checks').classList.toggle('hidden', !show);
$('#solution').classList.toggle('hidden', !q.solution);
if (q.solution) $('#solutionBody').textContent = q.solution;
if (show) $('#checks').innerHTML = `<b>${r.earned}/${r.total} points</b>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}">${esc(c.name)} <span style="color:var(--muted)">(${c.pts})</span></div>`).join('');
const i = S.questions.indexOf(q);
$('#btnPrev').disabled = i === 0; $('#btnNext').disabled = i === S.questions.length - 1;
}
if (sess && document.activeElement !== $('#notes')) $('#notes').value = sess.notes || '';
// terminal
const tw = $('#termwrap');
if (!tw.dataset.ready) {
tw.dataset.ready = 1;
tw.innerHTML = S.ttyd ? `<iframe id="term" src="http://127.0.0.1:${S.ttyd_port}/" title="terminal"></iframe>` :
`<div id="noterm">The terminal pane needs <code>ttyd</code>, which isn't installed.<br><br>
Install it (<code>dnf install ttyd</code>, <code>pacman -S ttyd</code>, <code>brew install ttyd</code>, or a release from github.com/tsl0922/ttyd)
and restart <code>./exam web</code>.<br><br>Until then, work in any terminal with kubectl pointed at the cluster.
Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`;
}
tickClock();
}
function tickClock() {
clearInterval(clockT);
const sess = S.session, el = $('#clock');
if (!sess || sess.ended_at) { el.textContent = sess ? 'ended' : '--:--:--'; el.className = ''; return; }
if (sess.untimed) { el.textContent = 'study'; el.className = 'paused'; return; }
const step = () => {
const now = Date.now()/1000 - skew;
const pausedTotal = sess.paused_total + (sess.paused ? (now - sess.server_time) : 0);
const left = sess.ends_at + pausedTotal - now;
el.textContent = (sess.paused ? '⏸ ' : '') + fmt(left);
el.className = sess.paused ? 'paused' : (left < 5*60 ? 'danger' : (left < 15*60 ? 'warn' : ''));
if (sess.paused) return;
if (left <= 0 && !autoSubmitted) { autoSubmitted = true; clearInterval(clockT); submit(); }
};
step(); clockT = setInterval(step, 1000);
}
async function load() {
S = await api('/api/session');
if (S.session) skew = Date.now()/1000 - S.session.server_time;
const sess = S.session;
const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {});
const total = Object.values(qn).reduce((a, b) => a + b, 0) || S.bank.length;
$('#examDesc').textContent = `${total} tasks sampled across the 5 domains, ${S.track.minutes} minutes, pass mark ${S.track.pass_pct}%. No feedback until you submit.`;
const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]);
$('#coverage').innerHTML = Object.keys(S.track.domains).map(d =>
`<span class="pill pick ${drilled[d] ? 'done' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks">${d} ${drilled[d] ? '✓' : '·'}</span>`).join('');
$('#coverage').querySelectorAll('[data-domain]').forEach(el => el.onclick = () => start('drill', null, false, false, el.dataset.domain));
const nxt = Object.keys(S.track.domains).sort((a, b) => (drilled[a] || 0) - (drilled[b] || 0))[0];
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.`;
for (const s of ['a', 'b']) {
const info = (S.sets || {})[s];
const el = $('#sim' + s.toUpperCase() + 'Desc');
if (el) el.textContent = info ? `${info.count} fixed tasks, ${S.track.minutes} min, exam rules. Hardest set in the bank — closest to a real sitting.` : '';
}
$('#gate').classList.toggle('hidden', !undone.length);
$('#gate').innerHTML = undone.length ? `Drill every domain once before the full exam. Not yet drilled: ${undone.join(', ')}. <a href="#" id="forceExam">Start the exam anyway</a>` : '';
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
$('#lobbyTitle').textContent = S.track.name;
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}">${t.toUpperCase()}</span>`).join('');
$('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => {
if (el.dataset.track === S.track_id) return;
S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
}); $('#ver').textContent = 'exam ' + (S.version || '?');
$('#lobbySub').textContent = sess && sess.ended_at ? `Last session ended with ${sess.score.pct}%. Start another, or review it under History.` : 'Choose a session. Seeding the cluster takes about a minute.';
if (sess && sess.ended_at && !$('#results').dataset.shown) showResults();
paint();
}
async function startSim(name) {
$('#lobbyOpts').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode: 'exam', sim: name, hard: true});
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) { if (s.seed_error) throw new Error(s.seed_error); S = s; break; }
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message; $('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function start(mode, ids, next, campaign, domain, study) {
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.add('hidden'); $('#lobbyErr').classList.add('hidden');
$('#seeding').classList.remove('hidden'); $('#seedLog').textContent = 'starting…';
try {
await api('/api/start', {mode, ids, next: !!next, campaign: !!campaign, domain: domain || null, study: !!study, hard: $('#hardMode').checked});
// seeding runs in the background; stream its log until it finishes
for (;;) {
await new Promise(r => setTimeout(r, 1200));
const s = await api('/api/session');
$('#seedLog').textContent = ((s.setup_log || []).join('\n') || 'starting…').replace(/\x1b\[[0-9;]*m/g, '');
$('#seedLog').scrollTop = $('#seedLog').scrollHeight;
if (!s.seeding) {
if (s.seed_error) throw new Error(s.seed_error);
S = s; break;
}
}
skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false;
$('#results').dataset.shown = ''; $('#results').classList.add('hidden'); cur = S.questions[0].id; paint();
} catch (e) {
$('#lobbyErr').textContent = e.message === 'Failed to fetch' ? 'Lost contact with ./exam web — is it still running? Restart it and reload this page.' : e.message;
$('#lobbyErr').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden');
} finally { $('#seeding').classList.add('hidden'); }
}
async function submit() {
$('#confirm').classList.add('hidden');
$('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…';
try { S = await api('/api/end', {}); } finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; }
showResults(); paint();
}
function showResults() {
const s = S.session, sc = s.score;
$('#results').dataset.shown = 1;
$('#resSub').textContent = `${s.mode === 'drill' ? 'Drill' : 'Full exam'} · ${fmt(s.ended_at - s.started_at - s.paused_total)} used${s.over_time ? ' (over time)' : ''}${s.pauses ? ` · paused ${s.pauses}× (${fmt(s.paused_total)})` : ''}`;
$('#resScore').innerHTML = `${sc.pct}% <small>${sc.earned} / ${sc.total} points</small>`;
const v = $('#resVerdict'); v.textContent = sc.pct >= sc.pass_pct ? 'Pass' : 'Fail'; v.className = 'verdict ' + (sc.pct >= sc.pass_pct ? 'pass' : 'fail');
$('#resDomains').innerHTML = Object.entries(s.domains).map(([d, x]) =>
`<div class="bar"><span>${d}</span><div class="track"><div class="fill" style="width:${100*x.earned/x.total}%"></div></div><span>${x.earned}/${x.total}</span></div>`).join('');
$('#resQuestions').innerHTML = S.questions.map(q => { const r = s.results[q.id]; if (!r) return '';
const cls = r.pass ? 't-pass' : (r.earned ? 't-partial' : 't-fail');
return `<details class="${cls}"><summary>Q${q.id} ${esc(q.title)}<span class="pts">${r.earned}/${r.total}</span></summary>` +
r.checks.map(c => `<div class="${c.pass ? 'ok' : 'no'}" style="padding-left:14px">${c.pass ? '✓' : '✗'} ${esc(c.name)} (${c.pts})</div>`).join('') +
(q.solution ? `<details style="margin:6px 0 4px 14px"><summary style="font-size:13px">Reference solution</summary><pre style="white-space:pre-wrap;font:12px/1.5 var(--mono);background:#f6f7f9;padding:8px;border-radius:4px">${esc(q.solution)}</pre></details>` : '') + '</details>'; }).join('');
const missed = S.questions.filter(q => s.results[q.id] && !s.results[q.id].pass).map(q => q.id);
$('#btnDrillMissed').disabled = !missed.length; $('#btnDrillMissed').onclick = () => { $('#results').classList.add('hidden'); start('drill', missed); };
$('#results').classList.remove('hidden'); $('#lobby').classList.add('hidden');
}
async function showHistory() {
const h = await api('/api/history');
$('#histSub').textContent = h.history.length ? `${h.history.length} attempt(s) on this track` : 'No attempts yet.';
$('#histBody').innerHTML = h.history.slice().reverse().map(e =>
`<div class="bar"><span>${e.mode}${e.hard ? '*' : ''}</span><div><b>${e.score_pct}%</b> · ${e.at.replace('T',' ')} · ${fmt(e.time_used)} · ${e.ids.length === S.questions.length ? 'all tasks' : 'Q' + e.ids.join(', Q')}</div><span></span></div>`).join('') +
(h.weak.length ? `<div style="margin-top:12px;color:var(--muted)">Weak or unattempted: Q${h.weak.join(', Q')}</div>` : '');
$('#histOv').classList.remove('hidden');
}
// ---- wiring ----
$('#optExam').onclick = () => start('exam');
$('#optNext').onclick = () => start('drill', null, true);
$('#optStudy').onclick = () => start('drill', null, true, false, null, true);
$('#optAll').onclick = () => start('drill', null, false, true);
$('#optSimA').onclick = () => startSim('a');
$('#optSimB').onclick = () => startSim('b');
$('#hardMode').onchange = () => load();
$('#optDrill').onclick = async () => {
const h = await api('/api/history');
const hard = $('#hardMode').checked;
$('#picker').innerHTML = S.bank.filter(q => hard || q.difficulty !== 'hard').map(q => `<label><input type="checkbox" value="${q.id}" ${h.weak.includes(q.id) ? 'checked' : ''}> Q${q.id} ${q.domain} ${esc(q.title)}${q.difficulty === 'hard' ? '<span class="hardtag">hard</span>' : ''}</label>`).join('');
$('#lobbyOpts').classList.add('hidden'); $('#drillPick').classList.remove('hidden');
};
$('#btnBackDrill').onclick = () => { $('#drillPick').classList.add('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnStartDrill').onclick = () => { const ids = [...$('#picker').querySelectorAll('input:checked')].map(i => +i.value); if (ids.length) start('drill', ids); };
$('#btnPrev').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i-1].id; paint(); };
$('#btnNext').onclick = () => { const i = S.questions.findIndex(q => q.id === cur); cur = S.questions[i+1].id; paint(); };
$('#btnFlag').onclick = async () => { S = await api('/api/flag', {id: cur}); paint(); };
$('#btnCheck').onclick = async () => { $('#btnCheck').disabled = true; try { S = await api('/api/grade', {id: cur}); paint(); } catch (e) { alert(e.message); } finally { $('#btnCheck').disabled = false; } };
let nt; $('#notes').oninput = () => { clearTimeout(nt); nt = setTimeout(() => api('/api/notes', {notes: $('#notes').value}), 600); };
$('#btnEnd').onclick = () => {
const f = S.session.flags, un = S.questions.filter(q => !S.session.results[q.id]).length;
$('#confirmFlags').innerHTML = f.length ? `<div>Flagged for review: Q${f.join(', Q')}</div>` : '';
$('#confirm').classList.remove('hidden');
};
$('#btnCancelEnd').onclick = () => $('#confirm').classList.add('hidden');
$('#btnConfirmEnd').onclick = submit;
$('#btnCloseRes').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.add('hidden'); };
$('#btnNewExam').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnHistory').onclick = showHistory;
$('#btnPause').onclick = async () => { S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now()/1000 - S.session.server_time; paint(); };
$('#btnCloseHist').onclick = () => $('#histOv').classList.add('hidden');
// draggable split
const handle = $('#handle'), main = document.querySelector('main');
handle.onmousedown = e => { e.preventDefault(); const mv = ev => { main.style.gridTemplateColumns = `56px ${Math.max(320, ev.clientX - 56)}px 6px 1fr`; };
const up = () => { removeEventListener('mousemove', mv); removeEventListener('mouseup', up); }; addEventListener('mousemove', mv); addEventListener('mouseup', up); };
load();
})();
</script>
</body>
</html>

View File

@ -0,0 +1,6 @@
apiVersion: v2
name: chart
description: A small web chart for the exam simulator
type: application
version: 0.1.0
appVersion: "1.27"

View File

@ -0,0 +1,3 @@
{{- define "chart.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

View File

@ -0,0 +1,17 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "chart.fullname" . }}
labels: {app: {{ include "chart.fullname" . }}}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {app: {{ include "chart.fullname" . }}}
template:
metadata:
labels: {app: {{ include "chart.fullname" . }}}
spec:
containers:
- name: web
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports: [{containerPort: 80}]

View File

@ -0,0 +1,8 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "chart.fullname" . }}
spec:
type: {{ .Values.service.type }}
selector: {app: {{ include "chart.fullname" . }}}
ports: [{port: {{ .Values.service.port }}, targetPort: 80}]

View File

@ -0,0 +1,7 @@
replicaCount: 1
image:
repository: nginx
tag: "1.27"
service:
type: ClusterIP
port: 80