docs: modified ckad track and all necessary tools
This commit is contained in:
parent
d3788be5e2
commit
5e3d593f79
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This guide is designed for fast mental retrieval during exam scenarios. It outlines **which command to choose based on the situation**, the exact syntax to run, and the official documentation shortcuts permitted during the exam.
|
This guide is designed for fast mental retrieval during exam scenarios. It outlines **which command to choose based on the situation**, the exact syntax to run, and the official documentation shortcuts permitted during the exam.
|
||||||
|
|
||||||
|
The CKA environment runs **Kubernetes v1.35** (etcd 3.6) as of October 2026, and so does this simulator's CKA track.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Quick Environment Setup & Muscle Memory
|
## 1. Quick Environment Setup & Muscle Memory
|
||||||
@ -52,7 +54,7 @@ EOF
|
|||||||
| **Control Plane Troubleshooting** | Inspect `/etc/kubernetes/manifests/` | Check `kube-apiserver.yaml`, `kube-scheduler.yaml`, `kube-controller-manager.yaml` |
|
| **Control Plane Troubleshooting** | Inspect `/etc/kubernetes/manifests/` | Check `kube-apiserver.yaml`, `kube-scheduler.yaml`, `kube-controller-manager.yaml` |
|
||||||
| **Etcd Backup** | `etcdctl snapshot save <file>` | `--endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=...` |
|
| **Etcd Backup** | `etcdctl snapshot save <file>` | `--endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=...` |
|
||||||
| **Etcd Restore** | `etcdutl snapshot restore <file>` | `--data-dir=/var/lib/etcd-backup` (update `etcd.yaml` volume hostPath). `etcdctl snapshot restore` is gone from etcd 3.6+ |
|
| **Etcd Restore** | `etcdutl snapshot restore <file>` | `--data-dir=/var/lib/etcd-backup` (update `etcd.yaml` volume hostPath). `etcdctl snapshot restore` is gone from etcd 3.6+ |
|
||||||
| **Upgrade Node (Control Plane / Worker)** | `apt-get update && apt-get install -y --allow-change-held-packages kubeadm=1.31.x-1.1` | `kubeadm upgrade plan` -> `kubeadm upgrade apply` -> upgrade `kubelet`/`kubectl` -> `systemctl restart kubelet` |
|
| **Upgrade Node (Control Plane / Worker)** | `apt-get update && apt-get install -y --allow-change-held-packages kubeadm='1.36.5-*'` | pkgs.k8s.io has one repo per minor: point `/etc/apt/sources.list.d/kubernetes.list` at `.../core:/stable:/v1.36/deb/` first. Then `kubeadm upgrade plan` -> `kubeadm upgrade apply v1.36.5` (workers: `kubeadm upgrade node`) -> upgrade `kubelet`/`kubectl` -> `systemctl restart kubelet` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -105,16 +107,17 @@ Then edit `/etc/kubernetes/manifests/etcd.yaml` to update the hostPath volume fo
|
|||||||
|
|
||||||
Restore is an offline file operation, so it needs no endpoints or certificates - and it lives in
|
Restore is an offline file operation, so it needs no endpoints or certificates - and it lives in
|
||||||
`etcdutl`, not `etcdctl`: `etcdctl snapshot restore` was deprecated in etcd 3.5 and is gone from
|
`etcdutl`, not `etcdctl`: `etcdctl snapshot restore` was deprecated in etcd 3.5 and is gone from
|
||||||
3.6+ (this cluster runs 3.7, where it fails with `unknown flag: --data-dir`). Check first with
|
3.6+ (the CKA track's v1.35 cluster runs 3.6, like the exam; there it fails with `unknown flag: --data-dir`). Check first with
|
||||||
`etcdctl version`; on a 3.5 node the old `ETCDCTL_API=3 etcdctl snapshot restore` still works.
|
`etcdctl version`; on a 3.5 node the old `ETCDCTL_API=3 etcdctl snapshot restore` still works.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Official Kubernetes Documentation URLs Allowed in CKA
|
## 5. Official Kubernetes Documentation URLs Allowed in CKA
|
||||||
- Kubernetes Documentation: `https://kubernetes.io/docs/`
|
Per the LF "Resources Allowed" page (checked 2026-10-02):
|
||||||
|
- Kubernetes Documentation: `https://kubernetes.io/docs/` (the kubectl cheatsheet lives here: `https://kubernetes.io/docs/reference/kubectl/cheatsheet/`)
|
||||||
- Kubernetes Blog: `https://kubernetes.io/blog/`
|
- Kubernetes Blog: `https://kubernetes.io/blog/`
|
||||||
- GitHub Kubernetes Organization: `https://github.com/kubernetes/`
|
- Helm Documentation: `https://helm.sh/docs`
|
||||||
- Cheatsheet: `https://kubernetes.io/docs/reference/kubectl/cheatsheet/`
|
- Gateway API Documentation (CKA only): `https://gateway-api.sigs.k8s.io`
|
||||||
|
|
||||||
### Pro Tip for Search in Docs:
|
### Pro Tip for Search in Docs:
|
||||||
Use the documentation search bar for exact resource types, e.g., `NetworkPolicy`, `PersistentVolume`, `Ingress`, or `kubeadm upgrade`. Copy YAML snippets directly from the docs and edit with `$do`.
|
Use the documentation search bar for exact resource types, e.g., `NetworkPolicy`, `PersistentVolume`, `Ingress`, or `kubeadm upgrade`. Copy YAML snippets directly from the docs and edit with `$do`.
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This guide explains how to approach and solve every question in the CKA exam bank. Every entry gives you the **underlying Kubernetes concept** (why the mechanism behaves the way it does), the **exact commands/manifests** that solve the task, and the **gotchas the grader is actually checking for** — the small details that separate a passing answer from a plausible-looking one. `k` is used throughout as the `kubectl` alias (already configured on the exam VMs).
|
This guide explains how to approach and solve every question in the CKA exam bank. Every entry gives you the **underlying Kubernetes concept** (why the mechanism behaves the way it does), the **exact commands/manifests** that solve the task, and the **gotchas the grader is actually checking for** — the small details that separate a passing answer from a plausible-looking one. `k` is used throughout as the `kubectl` alias (already configured on the exam VMs).
|
||||||
|
|
||||||
|
The CKA track runs on **Kubernetes v1.35** (kind cluster `cka-sim-135`, etcd 3.6, kubectl 1.35), the version the real CKA environment runs as of October 2026 — so `etcdutl`, not `etcdctl`, restores and inspects snapshots, and output matches what the exam's kubectl prints.
|
||||||
|
|
||||||
## Practicing the same ground on Killercoda
|
## Practicing the same ground on Killercoda
|
||||||
|
|
||||||
This bank mirrors the real CKA curriculum weights (25/15/20/10/30% across the five domains), and so does Killercoda's free, browser-based CKA scenario set. Killercoda groups its own scenarios into five domains too — the mapping below cross-references its actual scenario titles against the equivalent questions in this bank, so you know exactly which free scenario to run when a topic here feels shaky.
|
This bank mirrors the real CKA curriculum weights (25/15/20/10/30% across the five domains), and so does Killercoda's free, browser-based CKA scenario set. Killercoda groups its own scenarios into five domains too — the mapping below cross-references its actual scenario titles against the equivalent questions in this bank, so you know exactly which free scenario to run when a topic here feels shaky.
|
||||||
@ -304,7 +306,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
|||||||
k get node %{node} -o jsonpath='{.status.nodeInfo.containerRuntimeVersion}' # runtime
|
k get node %{node} -o jsonpath='{.status.nodeInfo.containerRuntimeVersion}' # runtime
|
||||||
k -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].spec.containers[0].image}' # parse tag after the colon
|
k -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].spec.containers[0].image}' # parse tag after the colon
|
||||||
```
|
```
|
||||||
- **Gotchas**: write exactly the value the cluster reports (`gitVersion`, not `major`/`minor` composed by hand) — a hand-typed guess like `v1.30` instead of the exact `v1.30.4` string will fail an exact-match check.
|
- **Gotchas**: write exactly the value the cluster reports (`gitVersion`, not `major`/`minor` composed by hand) — a hand-typed guess like `v1.35` instead of the exact `v1.35.8` string will fail an exact-match check.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
72
README.md
72
README.md
@ -6,8 +6,9 @@ Hands-on exam tasks graded **per check** against a real cluster, an exam clock,
|
|||||||
front end modelled on the real PSI exam, attempt history, and drills that rotate through the
|
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** (102 tasks: a random pool of 43 normal +
|
domains before you sit a full exam. Tracks: **CKA** (102 tasks: a random pool of 43 normal +
|
||||||
24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), **CKAD** (37 pool tasks, at
|
24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), **CKAD** (37 pool tasks, at
|
||||||
least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (scaffolded — same format, add
|
least one for every competency in the curriculum, plus a 20-task killer.sh-style Set A) and CKS (a
|
||||||
questions when you get there).
|
9-task starter bank in the same format). Each track runs on the Kubernetes version its real exam
|
||||||
|
runs — CKA and CKS on **v1.35**, CKAD on **v1.37** (see [Clusters and versions](#clusters-and-versions)).
|
||||||
|
|
||||||
## How a session is built
|
## How a session is built
|
||||||
|
|
||||||
@ -25,8 +26,9 @@ left behind, a relocated `staticPodPath`, two PVs where only one is right), exac
|
|||||||
graded byte-for-byte, compound failures (three faults on one node, two control-plane components
|
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,
|
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` /
|
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.
|
`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.
|
(CKA only; they point at the CKA cluster). Grading always runs against the track's own cluster
|
||||||
|
context regardless of which one you're on.
|
||||||
|
|
||||||
## Why kind and not k3s / hosted labs
|
## Why kind and not k3s / hosted labs
|
||||||
|
|
||||||
@ -38,14 +40,46 @@ of `ssh <node>` here; use the Vagrant/ssh mode below if you want a real ssh sess
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
podman, kind, kubectl, python3, **ttyd** (for the terminal pane in the browser UI).
|
podman, kind (v0.33.0 publishes both node images used here), kubectl, python3, **ttyd** (for the
|
||||||
~4 GB RAM for the 4-node cluster. Rootless podman needs cgroup v2; otherwise `sudo -E ./setup.sh`.
|
terminal pane in the browser UI). ~4 GB RAM per 4-node cluster — about 8 GB with both up; stop the
|
||||||
|
one you are not using with `podman stop $(podman ps -q --filter label=io.x-k8s.kind.cluster=cka-sim-137)` (`./setup.sh ckad`
|
||||||
|
starts it again). Rootless podman needs cgroup v2; otherwise `sudo -E ./setup.sh`.
|
||||||
Docker still works: `CONTAINER_RUNTIME=docker ./setup.sh`.
|
Docker still works: `CONTAINER_RUNTIME=docker ./setup.sh`.
|
||||||
|
|
||||||
|
## Clusters and versions
|
||||||
|
|
||||||
|
The real exams do not share a Kubernetes version, so neither do the tracks. Each Kubernetes minor
|
||||||
|
gets its own kind cluster, and every `./exam` command uses the cluster of its track:
|
||||||
|
|
||||||
|
| Track | Real exam runs | kind cluster | Context | Node image |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| CKA, CKS | v1.35 | `cka-sim-135` | `kind-cka-sim-135` | `kindest/node:v1.35.8` (etcd 3.6) |
|
||||||
|
| CKAD | v1.37 | `cka-sim-137` | `kind-cka-sim-137` | `kindest/node:v1.37.0` (etcd 3.7) |
|
||||||
|
|
||||||
|
Versions are from the Linux Foundation's exam instructions as of 2026-10-02 (CKAD moved from v1.35
|
||||||
|
to v1.37 at the end of September; the LF moves each exam on its own schedule). The domains and
|
||||||
|
weights did not change with it — the v1.37 CKAD curriculum is the v1.35 document. When an exam
|
||||||
|
moves, change `"k8s"` in its `TRACK` (and add the node image to `KIND_IMAGES` in `./exam` if it is
|
||||||
|
a new minor), then run `./setup.sh <track>`: tracks on the same minor share a cluster. The old
|
||||||
|
minor's cluster is then unused — delete it with `KIND_EXPERIMENTAL_PROVIDER=podman kind delete
|
||||||
|
cluster --name cka-sim-<old>` (plain `./teardown.sh` removes every cka-sim cluster).
|
||||||
|
|
||||||
|
`setup.sh` also downloads the kubectl that matches each cluster (`.state/kubectl/v1.35/kubectl`,
|
||||||
|
...), because the exam's kubectl matches its cluster and kubectl 1.36+ behaves differently in
|
||||||
|
places (`kubectl describe` with a selector hides Events, `kubectl debug` defaults to the `general`
|
||||||
|
profile). Every session start points the kubectl context *and* `.state/kubectl/current` at the
|
||||||
|
track's cluster, and `examrc` puts `current/` first on `PATH`, so your terminals follow. Only one
|
||||||
|
session can be open at a time across tracks (`--force` overrides on the command line). `./exam
|
||||||
|
clusters` shows what each track uses and whether it is up.
|
||||||
|
|
||||||
|
The single cluster `cka-sim` from before per-version clusters is not used any more:
|
||||||
|
`./teardown.sh legacy` deletes it.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```
|
```
|
||||||
./setup.sh # create the kind cluster, preload images (idempotent)
|
./setup.sh [cka|ckad|cks|all] # create that track's cluster, preload images (idempotent;
|
||||||
|
# default: the track ./exam defaults to - $CKA_TRACK, else cka)
|
||||||
./exam web # open http://127.0.0.1:8080 — start an exam or drill from there
|
./exam web # open http://127.0.0.1:8080 — start an exam or drill from there
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -60,8 +94,8 @@ source ./examrc.fish # fish equivalent
|
|||||||
|
|
||||||
That's the whole loop. The UI seeds the scenarios, runs the clock, lets you flag questions and
|
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
|
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
|
"Drill what I missed". The terminal pane is a real `bash` via ttyd with kubectl pointed at the
|
||||||
at the cluster (`--shell fish` if you'd rather practise in fish; the real exam is bash).
|
cluster of the session's track (`--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:
|
The same state is available from the terminal if you prefer:
|
||||||
|
|
||||||
@ -74,13 +108,15 @@ The same state is available from the terminal if you prefer:
|
|||||||
./exam list | show N | timer | status # during
|
./exam list | show N | timer | status # during
|
||||||
./exam end # submit
|
./exam end # submit
|
||||||
./exam weak | history # afterwards
|
./exam weak | history # afterwards
|
||||||
./exam --track ckad web # other tracks (or CKA_TRACK=ckad)
|
./exam --track ckad web # other tracks and their clusters (or CKA_TRACK=ckad)
|
||||||
./teardown.sh # delete cluster + state (history kept)
|
./exam clusters # Kubernetes version and cluster per track
|
||||||
|
./teardown.sh [cka|ckad|cks|legacy] # delete clusters + state (history kept; default all)
|
||||||
```
|
```
|
||||||
|
|
||||||
## CKAD track
|
## CKAD track
|
||||||
|
|
||||||
`./exam --track ckad ...` (every command takes it). A mock draws **16** tasks in 120 minutes,
|
`./exam --track ckad ...` (every command takes it), on its own Kubernetes v1.37 cluster
|
||||||
|
(`./setup.sh ckad`). A mock draws **16** tasks in 120 minutes,
|
||||||
weighted like the exam: Design and Build 3, Deployment 3, Observability 3, Environment and
|
weighted like the exam: Design and Build 3, Deployment 3, Observability 3, Environment and
|
||||||
Security 4, Networking 3, from a pool of 37 (8/7/7/10/5), so no two mocks are the same set. Every
|
Security 4, Networking 3, from a pool of 37 (8/7/7/10/5), so no two mocks are the same set. Every
|
||||||
line of the curriculum has at least one task: image builds, Jobs, DaemonSets, native sidecars,
|
line of the curriculum has at least one task: image builds, Jobs, DaemonSets, native sidecars,
|
||||||
@ -118,7 +154,9 @@ but left out of every statistic.
|
|||||||
|
|
||||||
## Using your own cluster instead of kind
|
## Using your own cluster instead of kind
|
||||||
|
|
||||||
Any kubeadm-style cluster with 1 control-plane + 3 workers works. Skip `setup.sh`, set (Fish):
|
Any kubeadm-style cluster with 1 control-plane + 3 workers works. Skip `setup.sh`, set (Fish) —
|
||||||
|
these override the cluster of every track, so use one on the minor of the track you practise
|
||||||
|
(session start warns when the version differs):
|
||||||
|
|
||||||
```
|
```
|
||||||
set -x CKA_CONTEXT kubernetes-admin@kubernetes
|
set -x CKA_CONTEXT kubernetes-admin@kubernetes
|
||||||
@ -136,7 +174,8 @@ The etcd tasks (2 and 45) need `etcdctl`/`etcdutl` **on the control-plane node**
|
|||||||
exam has them — they cannot be copied out of the running pod, since the etcd image is distroless.
|
exam has them — they cannot be copied out of the running pod, since the etcd image is distroless.
|
||||||
`setup.sh` installs them on kind; on any cluster, seeding those tasks installs them if they are
|
`setup.sh` installs them on kind; on any cluster, seeding those tasks installs them if they are
|
||||||
missing (from the etcd image in the node's containerd, else the upstream release, else apt) and
|
missing (from the etcd image in the node's containerd, else the upstream release, else apt) and
|
||||||
fails loudly if it cannot. `./etcd-tools.sh` does the same by hand.
|
fails loudly if it cannot. `./etcd-tools.sh [track]` does the same by hand. The CKA cluster runs
|
||||||
|
etcd 3.6, as the exam does: `etcdutl` restores and inspects snapshots, `etcdctl` only saves them.
|
||||||
|
|
||||||
## CKA bank
|
## CKA bank
|
||||||
|
|
||||||
@ -182,5 +221,6 @@ Banks live in `tracks/cka/{questions,bank_extra,bank_hard,bank_sim_*}.py`; a tas
|
|||||||
Scripts get `$K` (pinned to the grading context), `$WS`, `$CP/$W1/$W2/$W3`, `nx NODE 'cmd'`,
|
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}`,
|
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
|
`{W3}`, `{SHELL_CP}` etc. `TRACK["quota"]` / `["quota_hard"]` set how many tasks per domain a
|
||||||
session draws. For CKS, fill in `QUESTIONS` in the
|
session draws, `TRACK["k8s"]` the Kubernetes minor (and so the cluster) the track runs on, and
|
||||||
existing placeholder track; `TRACK` already carries the domain table and pass mark.
|
`TRACK["exam_contexts"]` whether it owns the `k8s-c*` contexts. For CKS, add to `QUESTIONS` in
|
||||||
|
its track; `TRACK` already carries the domain table and pass mark.
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
# Q02 (snapshot) / Q102 (restore) need them there). They cannot be copied out of the running
|
# Q02 (snapshot) / Q102 (restore) need them there). They cannot be copied out of the running
|
||||||
# etcd pod: registry.k8s.io/etcd is distroless, so there is no cat, sh or tar inside it.
|
# etcd pod: registry.k8s.io/etcd is distroless, so there is no cat, sh or tar inside it.
|
||||||
# Re-run this after ./setup.sh if the install was skipped or the network was down.
|
# Re-run this after ./setup.sh if the install was skipped or the network was down.
|
||||||
# ./etcd-tools.sh install them on the control-plane node (podman/docker clusters)
|
# ./etcd-tools.sh [TRACK] install them on the control-plane node of TRACK's cluster (default: the
|
||||||
|
# track ./exam defaults to; CKA_CP=<node> names the node directly)
|
||||||
# ./etcd-tools.sh --emit print the node-side script; ./exam pipes it through CKA_NODE_ACCESS,
|
# ./etcd-tools.sh --emit print the node-side script; ./exam pipes it through CKA_NODE_ACCESS,
|
||||||
# so ssh/vagrant clusters get the same install when a task is seeded
|
# so ssh/vagrant clusters get the same install when a task is seeded
|
||||||
|
# Version-agnostic: it takes whatever etcd the node runs (3.6.x on v1.35, 3.7.x on v1.37).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
@ -67,6 +69,6 @@ fi
|
|||||||
|
|
||||||
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
|
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
|
||||||
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
|
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
|
||||||
CP="${CKA_CP:-cka-sim-control-plane}"
|
CP="${CKA_CP:-$(./exam ${1:+--track "$1"} cluster-env | sed -n 's/^CLUSTER=//p')-control-plane}"
|
||||||
printf '%s: ' "$CP"
|
printf '%s: ' "$CP"
|
||||||
printf '%s\n' "$NODE_SCRIPT" | "$RT" exec -i "$CP" sh -s
|
printf '%s\n' "$NODE_SCRIPT" | "$RT" exec -i "$CP" sh -s
|
||||||
|
|||||||
243
exam
243
exam
@ -38,6 +38,23 @@ PASS_PCT = 0
|
|||||||
STATE_FILE = ""
|
STATE_FILE = ""
|
||||||
HISTORY_FILE = ""
|
HISTORY_FILE = ""
|
||||||
WEAK_FILE = ""
|
WEAK_FILE = ""
|
||||||
|
K8S = "" # Kubernetes minor the track's real exam runs, e.g. "v1.35"
|
||||||
|
CLUSTER = "" # kind cluster for that minor
|
||||||
|
CONTEXT = ""
|
||||||
|
NODES = {}
|
||||||
|
|
||||||
|
# One kind cluster per Kubernetes minor an exam runs on, so tracks on the same minor share one
|
||||||
|
# (CKA and CKS today) and moving an exam to a new minor is a one-line change to its TRACK["k8s"].
|
||||||
|
# The images are the ones the kind v0.33.0 release notes publish; kind only guarantees an image
|
||||||
|
# built for its own release when it is pinned by digest.
|
||||||
|
KIND_IMAGES = {
|
||||||
|
"v1.35": "kindest/node:v1.35.8@sha256:07b2536e30b803ed61d1677a79df6115f798ce64c80f9e22f6ed45afd09323c0",
|
||||||
|
"v1.37": "kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5",
|
||||||
|
}
|
||||||
|
|
||||||
|
def kind_cluster(k8s):
|
||||||
|
"""Name of the kind cluster for a Kubernetes minor: v1.35 -> cka-sim-135."""
|
||||||
|
return "cka-sim-" + k8s.lstrip("v").replace(".", "")
|
||||||
|
|
||||||
|
|
||||||
def load_track(track_id):
|
def load_track(track_id):
|
||||||
@ -45,13 +62,14 @@ def load_track(track_id):
|
|||||||
try:
|
try:
|
||||||
mod = importlib.import_module(f"tracks.{track_id}.questions")
|
mod = importlib.import_module(f"tracks.{track_id}.questions")
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
print(f"unknown track '{track_id}' (no tracks/{track_id}/questions.py)"); sys.exit(1)
|
sys.exit(f"unknown track '{track_id}' (no tracks/{track_id}/questions.py)")
|
||||||
importlib.reload(mod)
|
importlib.reload(mod)
|
||||||
track, questions = mod.TRACK, list(mod.QUESTIONS)
|
track, questions = mod.TRACK, list(mod.QUESTIONS)
|
||||||
for q in questions:
|
for q in questions:
|
||||||
q["weight"] = sum(c["pts"] for c in q["checks"])
|
q["weight"] = sum(c["pts"] for c in q["checks"])
|
||||||
q.setdefault("difficulty", "normal")
|
q.setdefault("difficulty", "normal")
|
||||||
q.setdefault("set", None)
|
q.setdefault("set", None)
|
||||||
|
cluster = kind_cluster(track["k8s"])
|
||||||
g = globals()
|
g = globals()
|
||||||
g.update(
|
g.update(
|
||||||
TRACK_ID=track_id, TRACK=track, QUESTIONS=questions, DOMAINS=track["domains"],
|
TRACK_ID=track_id, TRACK=track, QUESTIONS=questions, DOMAINS=track["domains"],
|
||||||
@ -63,6 +81,13 @@ def load_track(track_id):
|
|||||||
STATE_FILE=os.path.join(ROOT, ".state", f"state-{track_id}.json"),
|
STATE_FILE=os.path.join(ROOT, ".state", f"state-{track_id}.json"),
|
||||||
HISTORY_FILE=os.path.join(ROOT, f"history-{track_id}.jsonl"),
|
HISTORY_FILE=os.path.join(ROOT, f"history-{track_id}.jsonl"),
|
||||||
WEAK_FILE=os.path.join(ROOT, f"weak-spots-{track_id}.md"),
|
WEAK_FILE=os.path.join(ROOT, f"weak-spots-{track_id}.md"),
|
||||||
|
# The CKA_* variables point every track at your own cluster instead (see the README).
|
||||||
|
K8S=track["k8s"], CLUSTER=cluster,
|
||||||
|
CONTEXT=os.environ.get("CKA_CONTEXT", f"kind-{cluster}"),
|
||||||
|
NODES={"CP": os.environ.get("CKA_CP", f"{cluster}-control-plane"),
|
||||||
|
"W1": os.environ.get("CKA_W1", f"{cluster}-worker"),
|
||||||
|
"W2": os.environ.get("CKA_W2", f"{cluster}-worker2"),
|
||||||
|
"W3": os.environ.get("CKA_W3", f"{cluster}-worker3")},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -81,20 +106,17 @@ DRILL_SIZE = 5
|
|||||||
import random
|
import random
|
||||||
NAMESPACES_TO_SETTLE = ["apps", "netpol", "svc", "ingress-app", "dns-check", "storage"]
|
NAMESPACES_TO_SETTLE = ["apps", "netpol", "svc", "ingress-app", "dns-check", "storage"]
|
||||||
|
|
||||||
# --- cluster targeting (override with env vars to use a non-kind cluster) ---
|
# --- cluster targeting: CONTEXT and NODES follow the track (load_track); env vars override ---
|
||||||
CONTEXT = os.environ.get("CKA_CONTEXT", "kind-cka-sim")
|
|
||||||
def _default_access():
|
def _default_access():
|
||||||
try:
|
try:
|
||||||
return open(os.path.join(STATE_DIR, "runtime")).read().strip() # written by setup.sh
|
return open(os.path.join(STATE_DIR, "runtime")).read().strip() # written by setup.sh
|
||||||
except OSError:
|
except OSError:
|
||||||
return "podman"
|
return "podman"
|
||||||
NODE_ACCESS = os.environ.get("CKA_NODE_ACCESS", _default_access()) # podman | docker | ssh | vagrant
|
NODE_ACCESS = os.environ.get("CKA_NODE_ACCESS", _default_access()) # podman | docker | ssh | vagrant
|
||||||
NODES = {
|
# kubectl matching each cluster's server version, fetched by setup.sh: .state/kubectl/v1.35/kubectl.
|
||||||
"CP": os.environ.get("CKA_CP", "cka-sim-control-plane"),
|
# .state/kubectl/current links to the one for the cluster of the latest session; examrc puts it
|
||||||
"W1": os.environ.get("CKA_W1", "cka-sim-worker"),
|
# first on PATH, so your terminals switch kubectl version together with the cluster.
|
||||||
"W2": os.environ.get("CKA_W2", "cka-sim-worker2"),
|
KUBECTL_DIR = os.path.join(STATE_DIR, "kubectl")
|
||||||
"W3": os.environ.get("CKA_W3", "cka-sim-worker3"),
|
|
||||||
}
|
|
||||||
def shell_hint(node):
|
def shell_hint(node):
|
||||||
return {"podman": f"podman exec -it {node} bash",
|
return {"podman": f"podman exec -it {node} bash",
|
||||||
"docker": f"docker exec -it {node} bash",
|
"docker": f"docker exec -it {node} bash",
|
||||||
@ -103,13 +125,12 @@ def shell_hint(node):
|
|||||||
|
|
||||||
PRELUDE = r"""
|
PRELUDE = r"""
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
K="kubectl --context ${CKA_CONTEXT:-kind-cka-sim}" # grading never depends on the candidate's current context
|
K="kubectl --context ${CKA_CONTEXT:?}" # grading never depends on the candidate's current context
|
||||||
WS="$WORKSPACE"
|
WS="$WORKSPACE"
|
||||||
# j [kubectl get args...] '{jsonpath}' -> prints the jsonpath result ("" on error)
|
# j [kubectl get args...] '{jsonpath}' -> prints the jsonpath result ("" on error)
|
||||||
j() { local n=$#; local path="${!n}"; $K get "${@:1:n-1}" -o "jsonpath=$path" 2>/dev/null; }
|
j() { local n=$#; local path="${!n}"; $K get "${@:1:n-1}" -o "jsonpath=$path" 2>/dev/null; }
|
||||||
eq() { [ "$1" = "$2" ]; }
|
eq() { [ "$1" = "$2" ]; }
|
||||||
CP="${CKA_CP:-cka-sim-control-plane}"; W1="${CKA_W1:-cka-sim-worker}"
|
CP="${CKA_CP:?}"; W1="${CKA_W1:?}"; W2="${CKA_W2:?}"; W3="${CKA_W3:?}" # run() exports the track's nodes
|
||||||
W2="${CKA_W2:-cka-sim-worker2}"; W3="${CKA_W3:-cka-sim-worker3}"
|
|
||||||
# nx NODE 'command' -> run a shell command on a node (stdin is forwarded)
|
# nx NODE 'command' -> run a shell command on a node (stdin is forwarded)
|
||||||
nx() {
|
nx() {
|
||||||
local n="$1"; shift
|
local n="$1"; shift
|
||||||
@ -131,18 +152,20 @@ ensure_etcd_tools() {
|
|||||||
# Q74 need them at seed time; a cluster rebuilt by ./setup.sh has none until one of them installs it.
|
# Q74 need them at seed time; a cluster rebuilt by ./setup.sh has none until one of them installs it.
|
||||||
ensure_gateway_crds() {
|
ensure_gateway_crds() {
|
||||||
$K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 && return 0
|
$K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 && return 0
|
||||||
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml -o /tmp/gwapi.yaml \
|
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml \
|
||||||
&& $K apply -f /tmp/gwapi.yaml >/dev/null 2>&1 \
|
| $K apply -f - >/dev/null 2>&1 \
|
||||||
&& $K wait --for=condition=Established --timeout=60s crd/gatewayclasses.gateway.networking.k8s.io \
|
&& $K wait --for=condition=Established --timeout=60s crd/gatewayclasses.gateway.networking.k8s.io \
|
||||||
crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io >/dev/null 2>&1
|
crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
# ensure_metrics_server -> a working metrics API for `kubectl top` (CKAD Q23). kind ships none, and
|
# ensure_metrics_server -> a working metrics API for `kubectl top` (CKAD Q23). kind ships none, and
|
||||||
# kind's kubelet serving certs have no IP SANs, so it only becomes Ready with --kubelet-insecure-tls.
|
# kind's kubelet serving certs have no IP SANs, so it only becomes Ready with --kubelet-insecure-tls.
|
||||||
# CKA Q23 strips that flag on purpose; the baseline puts it back before every session.
|
# CKA Q23 strips that flag on purpose; the baseline puts it back before every session.
|
||||||
|
# Pinned: v0.9.x supports Kubernetes 1.34+, so it serves the v1.35 and v1.37 clusters alike, and a
|
||||||
|
# future "latest" that drops 1.35 cannot break the CKA track.
|
||||||
|
METRICS_SERVER_URL=https://github.com/kubernetes-sigs/metrics-server/releases/download/v0.9.0/components.yaml
|
||||||
ensure_metrics_server() {
|
ensure_metrics_server() {
|
||||||
if ! $K -n kube-system get deploy metrics-server >/dev/null 2>&1; then
|
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 \
|
curl -sfL --max-time 60 "$METRICS_SERVER_URL" | $K apply -f - >/dev/null 2>&1 || return 1
|
||||||
&& $K apply -f /tmp/ms.yaml >/dev/null 2>&1 || return 1
|
|
||||||
fi
|
fi
|
||||||
if ! $K -n kube-system get deploy metrics-server -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q kubelet-insecure-tls; then
|
if ! $K -n kube-system get deploy metrics-server -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q kubelet-insecure-tls; then
|
||||||
$K -n kube-system patch deploy metrics-server --type=json \
|
$K -n kube-system patch deploy metrics-server --type=json \
|
||||||
@ -168,8 +191,13 @@ def red(s): return c("31", s)
|
|||||||
def yellow(s): return c("33", s)
|
def yellow(s): return c("33", s)
|
||||||
def bold(s): return c("1", s)
|
def bold(s): return c("1", s)
|
||||||
def dim(s): return c("2", s)
|
def dim(s): return c("2", s)
|
||||||
|
class Die(SystemExit):
|
||||||
|
"""SystemExit that carries its message, so the web UI can show why a start was refused."""
|
||||||
|
|
||||||
def die(msg, code=1):
|
def die(msg, code=1):
|
||||||
print(red(msg)); sys.exit(code)
|
print(red(msg))
|
||||||
|
e = Die(code); e.msg = msg
|
||||||
|
raise e
|
||||||
|
|
||||||
# ------------------------------------------------------------- state ----
|
# ------------------------------------------------------------- state ----
|
||||||
def load_state() -> dict[str, Any] | None:
|
def load_state() -> dict[str, Any] | None:
|
||||||
@ -220,6 +248,17 @@ def require_started() -> dict[str, Any]:
|
|||||||
assert st is not None
|
assert st is not None
|
||||||
return st
|
return st
|
||||||
|
|
||||||
|
_TRACK_K8S: dict[str, str] = {}
|
||||||
|
def track_k8s():
|
||||||
|
"""Each track's Kubernetes minor, for the lobby's track picker (looked up once per process)."""
|
||||||
|
if not _TRACK_K8S:
|
||||||
|
for t in TRACKS:
|
||||||
|
try:
|
||||||
|
_TRACK_K8S[t] = importlib.import_module(f"tracks.{t}.questions").TRACK.get("k8s", "?")
|
||||||
|
except Exception: # noqa: BLE001 - a bank mid-edit must not take the other tracks' UI down
|
||||||
|
_TRACK_K8S[t] = "?"
|
||||||
|
return _TRACK_K8S
|
||||||
|
|
||||||
def session_questions(st: dict[str, Any] | None) -> list[dict[str, Any]]:
|
def session_questions(st: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
if not st:
|
if not st:
|
||||||
return list(QUESTIONS)
|
return list(QUESTIONS)
|
||||||
@ -269,19 +308,34 @@ def make_params(q, rng):
|
|||||||
return fn(rng) if fn else {}
|
return fn(rng) if fn else {}
|
||||||
|
|
||||||
# -------------------------------------------------------------- shell ----
|
# -------------------------------------------------------------- shell ----
|
||||||
|
def kubectl_path():
|
||||||
|
"""PATH with the kubectl matching this track's Kubernetes minor first, when setup.sh fetched one."""
|
||||||
|
d = os.path.join(KUBECTL_DIR, K8S)
|
||||||
|
path = os.environ.get("PATH", "")
|
||||||
|
return f"{d}{os.pathsep}{path}" if os.path.isfile(os.path.join(d, "kubectl")) else path
|
||||||
|
|
||||||
def run(script, timeout=180, params=None):
|
def run(script, timeout=180, params=None):
|
||||||
env = dict(os.environ, WORKSPACE=WORKSPACE, CKA_ROOT=ROOT, CKA_NODE_ACCESS=NODE_ACCESS, CKA_CONTEXT=CONTEXT,
|
env = dict(os.environ, WORKSPACE=WORKSPACE, CKA_ROOT=ROOT, CKA_NODE_ACCESS=NODE_ACCESS, CKA_CONTEXT=CONTEXT,
|
||||||
**{f"CKA_{k}": v for k, v in NODES.items()})
|
PATH=kubectl_path(), **{f"CKA_{k}": v for k, v in NODES.items()})
|
||||||
p = subprocess.run(["bash", "-c", PRELUDE + render(script, params)], env=env, capture_output=True, text=True,
|
p = subprocess.run(["bash", "-c", PRELUDE + render(script, params)], env=env, capture_output=True, text=True,
|
||||||
timeout=timeout, stdin=subprocess.DEVNULL)
|
timeout=timeout, stdin=subprocess.DEVNULL)
|
||||||
return p.returncode, (p.stdout + p.stderr).strip()
|
return p.returncode, (p.stdout + p.stderr).strip()
|
||||||
|
|
||||||
def kubectl_ok():
|
def server_version():
|
||||||
|
"""The cluster's gitVersion (e.g. v1.35.8), or None when it cannot be reached."""
|
||||||
try:
|
try:
|
||||||
return subprocess.run(["kubectl", "--context", CONTEXT, "get", "--raw=/version"],
|
p = subprocess.run(["kubectl", "--context", CONTEXT, "get", "--raw=/version"],
|
||||||
capture_output=True, text=True, timeout=15).returncode == 0
|
capture_output=True, text=True, timeout=15)
|
||||||
|
return json.loads(p.stdout).get("gitVersion") if p.returncode == 0 else None
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
|
def kubectl_ok():
|
||||||
|
return server_version() is not None
|
||||||
|
|
||||||
|
def setup_hint():
|
||||||
|
"""How to (re)build this track's cluster."""
|
||||||
|
return f"./setup.sh {TRACK_ID}"
|
||||||
|
|
||||||
def grade_one(q, params=None):
|
def grade_one(q, params=None):
|
||||||
"""Run every check independently. Returns a result dict with partial credit."""
|
"""Run every check independently. Returns a result dict with partial credit."""
|
||||||
@ -322,6 +376,7 @@ def load_history(include_synthetic=False):
|
|||||||
def record_attempt(st):
|
def record_attempt(st):
|
||||||
earned, total, pct = score(st)
|
earned, total, pct = score(st)
|
||||||
entry = {"at": datetime.now().isoformat(timespec="minutes"), "mode": st.get("mode", "exam"), "hard": st.get("hard", False),
|
entry = {"at": datetime.now().isoformat(timespec="minutes"), "mode": st.get("mode", "exam"), "hard": st.get("hard", False),
|
||||||
|
"k8s": st.get("k8s"), # server version it ran on; older entries predate per-track clusters
|
||||||
"ids": [q["id"] for q in session_questions(st)], "score_pct": pct, "earned": earned, "total": total,
|
"ids": [q["id"] for q in session_questions(st)], "score_pct": pct, "earned": earned, "total": total,
|
||||||
"time_used": int(st["ended_at"] - st["started_at"] - paused_total(st)),
|
"time_used": int(st["ended_at"] - st["started_at"] - paused_total(st)),
|
||||||
"results": {k: {"pass": v["pass"], "earned": v["earned"], "total": v["total"],
|
"results": {k: {"pass": v["pass"], "earned": v["earned"], "total": v["total"],
|
||||||
@ -595,10 +650,15 @@ def preflight():
|
|||||||
rc, _ = run('nx "$CP" "true" </dev/null && nx "$W1" "true" </dev/null && nx "$W2" "true" </dev/null && nx "$W3" "true" </dev/null', timeout=60)
|
rc, _ = run('nx "$CP" "true" </dev/null && nx "$W1" "true" </dev/null && nx "$W2" "true" </dev/null && nx "$W3" "true" </dev/null', timeout=60)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
die(f"Cannot run commands on nodes with CKA_NODE_ACCESS={NODE_ACCESS}.\n"
|
die(f"Cannot run commands on nodes with CKA_NODE_ACCESS={NODE_ACCESS}.\n"
|
||||||
" podman/docker -> node names must be kind container names (kind get nodes --name cka-sim)\n"
|
f" podman/docker -> node names must be kind container names (podman ps --filter label=io.x-k8s.kind.cluster={CLUSTER})\n"
|
||||||
" ssh -> passwordless ssh to each node name must work\n"
|
" ssh -> passwordless ssh to each node name must work\n"
|
||||||
" vagrant -> run from the Vagrantfile directory; names are vagrant machine names")
|
" vagrant -> run from the Vagrantfile directory; names are vagrant machine names")
|
||||||
print(dim(f" track: {TRACK_ID} cluster: {CONTEXT} node access: {NODE_ACCESS} nodes: {', '.join(NODES.values())}"))
|
ver = server_version() or "?"
|
||||||
|
print(dim(f" track: {TRACK_ID} cluster: {CONTEXT} ({ver}) node access: {NODE_ACCESS} nodes: {', '.join(NODES.values())}"))
|
||||||
|
# The real exams pin one minor each; practising on another one teaches the wrong defaults.
|
||||||
|
if not ver.startswith(K8S + "."):
|
||||||
|
fix = f"rebuild it: {setup_hint()} --recreate" if CONTEXT == f"kind-{CLUSTER}" else "point CKA_CONTEXT at a cluster on that minor"
|
||||||
|
print(yellow(f" warning: this cluster runs {ver}, but the real {TRACK_ID.upper()} exam runs Kubernetes {K8S} - {fix}"))
|
||||||
for tool, why in (("helm", "Helm tasks (the real exam has helm on the terminal)"), ("openssl", "certificate tasks"), ("curl", "tasks that fetch manifests")):
|
for tool, why in (("helm", "Helm tasks (the real exam has helm on the terminal)"), ("openssl", "certificate tasks"), ("curl", "tasks that fetch manifests")):
|
||||||
if not shutil.which(str(tool)):
|
if not shutil.which(str(tool)):
|
||||||
print(yellow(f" warning: '{tool}' not found on this machine - needed for {why}"))
|
print(yellow(f" warning: '{tool}' not found on this machine - needed for {why}"))
|
||||||
@ -628,7 +688,13 @@ def setup_contexts(hard, params_by_id):
|
|||||||
`kubectl config use-context k8s-c?-?` and are unfollowable without it - not only when the
|
`kubectl config use-context k8s-c?-?` and are unfollowable without it - not only when the
|
||||||
whole session was started with --hard. A drill that names hard task ids explicitly
|
whole session was started with --hard. A drill that names hard task ids explicitly
|
||||||
('./exam drill 121', './exam drill weak') pulls those tasks in with hard=False.
|
('./exam drill 121', './exam drill weak') pulls those tasks in with hard=False.
|
||||||
|
|
||||||
|
Only tracks with TRACK["exam_contexts"] (CKA) use them, so only those tracks create or delete
|
||||||
|
them: the others run on another cluster, and must not repoint or remove the CKA contexts.
|
||||||
"""
|
"""
|
||||||
|
if not TRACK.get("exam_contexts"):
|
||||||
|
subprocess.run(["kubectl", "config", "use-context", CONTEXT], capture_output=True)
|
||||||
|
return
|
||||||
cluster = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.cluster}}"],
|
cluster = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.cluster}}"],
|
||||||
capture_output=True, text=True).stdout.strip()
|
capture_output=True, text=True).stdout.strip()
|
||||||
user = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.user}}"],
|
user = subprocess.run(["kubectl", "config", "view", "-o", f"jsonpath={{.contexts[?(@.name=='{CONTEXT}')].context.user}}"],
|
||||||
@ -675,7 +741,22 @@ if nx "$CP" 'grep -q "^ *- kube-apiserverr$" /etc/kubernetes/manifests/kube-apis
|
|||||||
nx "$CP" "$MEDIT"'
|
nx "$CP" "$MEDIT"'
|
||||||
medit /etc/kubernetes/manifests/kube-apiserver.yaml -e "s|^\( *\)- kube-apiserverr$|\1- kube-apiserver|"' </dev/null
|
medit /etc/kubernetes/manifests/kube-apiserver.yaml -e "s|^\( *\)- kube-apiserverr$|\1- kube-apiserver|"' </dev/null
|
||||||
# the kubelet re-reads the manifest on its own; wait for the API to answer again
|
# the kubelet re-reads the manifest on its own; wait for the API to answer again
|
||||||
for _ in $(seq 1 30); do kubectl --context "${CKA_CONTEXT:-kind-cka-sim}" get --raw=/readyz >/dev/null 2>&1 && break; sleep 2; done
|
for _ in $(seq 1 30); do $K get --raw=/readyz >/dev/null 2>&1 && break; sleep 2; done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# apiserver audit flags (CKS Q9, whose track shares this cluster with CKA): a half-finished answer -
|
||||||
|
# flags without the volume, or a hostPath of type File whose policy file is gone - keeps the API server
|
||||||
|
# down for every later session. Touched only when the API server is down and audit flags are present,
|
||||||
|
# so a working audit setup is left alone.
|
||||||
|
if nx "$CP" 'grep -q -- "--audit-" /etc/kubernetes/manifests/kube-apiserver.yaml' </dev/null 2>/dev/null; then
|
||||||
|
for _ in $(seq 1 10); do $K get --raw=/readyz >/dev/null 2>&1 && break; sleep 3; done
|
||||||
|
if ! $K get --raw=/readyz >/dev/null 2>&1; then
|
||||||
|
nx "$CP" 'mkdir -p /var/log/kubernetes
|
||||||
|
[ -f /etc/kubernetes/audit-policy.yaml ] || printf "apiVersion: audit.k8s.io/v1\nkind: Policy\nrules:\n- level: None\n" > /etc/kubernetes/audit-policy.yaml' </dev/null
|
||||||
|
nx "$CP" "$MEDIT"'
|
||||||
|
medit /etc/kubernetes/manifests/kube-apiserver.yaml -e "/--audit-/d"' </dev/null
|
||||||
|
for _ in $(seq 1 45); do $K get --raw=/readyz >/dev/null 2>&1 && break; sleep 2; done
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# scheduler: undo the "kube-scheduler" -> "kube-schedulerr" command typo (Q20/Q61), and put the
|
# scheduler: undo the "kube-scheduler" -> "kube-schedulerr" command typo (Q20/Q61), and put the
|
||||||
@ -828,12 +909,69 @@ def seed(qs, log: Callable[..., Any] = _log_msg, params_by_id=None):
|
|||||||
for q in disruptive:
|
for q in disruptive:
|
||||||
one(q)
|
one(q)
|
||||||
|
|
||||||
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False):
|
def open_sessions_elsewhere():
|
||||||
|
"""Tracks other than this one with a session that has not ended. Sessions share the kubeconfig's
|
||||||
|
current context (and kubectl version), so starting one would pull the other's terminal over to
|
||||||
|
another cluster while its grading stays on the old one: silent zeros."""
|
||||||
|
out = []
|
||||||
|
for t in TRACKS:
|
||||||
|
if t == TRACK_ID:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(os.path.join(STATE_DIR, f"state-{t}.json")) as f:
|
||||||
|
if not json.load(f).get("ended_at"):
|
||||||
|
out.append(t)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
def use_kubectl():
|
||||||
|
"""Point .state/kubectl/current at the kubectl for this track's minor, or at your own kubectl
|
||||||
|
('host') when setup.sh could not fetch one. examrc puts current/ first on PATH; it always exists
|
||||||
|
once setup.sh has run, so a shell that already hashed current/kubectl follows the switch."""
|
||||||
|
if not os.path.isdir(KUBECTL_DIR):
|
||||||
|
return
|
||||||
|
target = K8S
|
||||||
|
if not os.path.isfile(os.path.join(KUBECTL_DIR, K8S, "kubectl")):
|
||||||
|
target = "host"
|
||||||
|
mine = os.path.realpath(KUBECTL_DIR) # never our own current/ - PATH may spell it via a symlink
|
||||||
|
own = shutil.which("kubectl", path=os.pathsep.join(
|
||||||
|
p for p in os.environ.get("PATH", "").split(os.pathsep) if not os.path.realpath(p).startswith(mine)))
|
||||||
|
if not own:
|
||||||
|
return
|
||||||
|
link = os.path.join(KUBECTL_DIR, "host", "kubectl")
|
||||||
|
os.makedirs(os.path.dirname(link), exist_ok=True)
|
||||||
|
if os.path.realpath(link) != os.path.realpath(own):
|
||||||
|
if os.path.lexists(link):
|
||||||
|
os.remove(link)
|
||||||
|
os.symlink(os.path.realpath(own), link)
|
||||||
|
cur = os.path.join(KUBECTL_DIR, "current")
|
||||||
|
tmp = cur + ".new"
|
||||||
|
try:
|
||||||
|
if os.path.lexists(tmp):
|
||||||
|
os.remove(tmp)
|
||||||
|
os.symlink(target, tmp)
|
||||||
|
os.replace(tmp, cur) # atomic: a terminal never sees current/ missing
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg, hard=False, untimed_session=False, other_ok=None):
|
||||||
st = load_state()
|
st = load_state()
|
||||||
if st and not st.get("ended_at") and not force:
|
if st and not st.get("ended_at") and not force:
|
||||||
die("A session is already in progress. './exam end' it first, or add --force.")
|
die("A session is already in progress. './exam end' it first, or add --force.")
|
||||||
|
others = open_sessions_elsewhere()
|
||||||
|
if others and not (force if other_ok is None else other_ok):
|
||||||
|
t = others[0]
|
||||||
|
how = (f"switch to {t.upper()} in the lobby and submit it, or run ./exam --track {t} end (abort --yes discards it)"
|
||||||
|
if other_ok is False else f"./exam --track {t} end (or abort --yes), or add --force here")
|
||||||
|
die(f"A {t.upper()} session is still open. Tracks run on their own clusters but share your kubectl context,\n"
|
||||||
|
f" so finish that one first: {how}.")
|
||||||
|
if others:
|
||||||
|
log(yellow(f" warning: the open {'/'.join(o.upper() for o in others)} session loses its kubectl context and kubectl"
|
||||||
|
f" version to this {TRACK_ID.upper()} session; end it before going back to it."))
|
||||||
if not kubectl_ok():
|
if not kubectl_ok():
|
||||||
die(f"Cannot reach the cluster through kubectl context '{CONTEXT}'. Run ./setup.sh (kind) or set CKA_CONTEXT for another cluster.")
|
die(f"Cannot reach the {TRACK_ID.upper()} cluster through kubectl context '{CONTEXT}' (Kubernetes {K8S}).\n"
|
||||||
|
f" Create or restart it: {setup_hint()} (or set CKA_CONTEXT for your own cluster)")
|
||||||
if not qs:
|
if not qs:
|
||||||
die(f"Track '{TRACK_ID}' has no questions yet.")
|
die(f"Track '{TRACK_ID}' has no questions yet.")
|
||||||
preflight()
|
preflight()
|
||||||
@ -841,17 +979,19 @@ def begin_session(qs, mode, duration, force, log: Callable[..., Any] = _log_msg,
|
|||||||
rng = random.Random()
|
rng = random.Random()
|
||||||
params_by_id = {str(q["id"]): make_params(q, rng) for q in qs}
|
params_by_id = {str(q["id"]): make_params(q, rng) for q in qs}
|
||||||
setup_contexts(hard, params_by_id)
|
setup_contexts(hard, params_by_id)
|
||||||
|
use_kubectl()
|
||||||
restore_baseline(log)
|
restore_baseline(log)
|
||||||
log(bold(f"Seeding {len(qs)} scenario(s){' [HARD]' if hard else ''}..."))
|
log(bold(f"Seeding {len(qs)} scenario(s){' [HARD]' if hard else ''}..."))
|
||||||
seed(qs, log, params_by_id)
|
seed(qs, log, params_by_id)
|
||||||
st = {"mode": mode, "track": TRACK_ID, "hard": hard, "untimed": untimed_session, "ids": [q["id"] for q in qs],
|
st = {"mode": mode, "track": TRACK_ID, "hard": hard, "untimed": untimed_session, "ids": [q["id"] for q in qs],
|
||||||
"params": params_by_id, "duration": duration, "started_at": time.time(), "ended_at": None,
|
"params": params_by_id, "duration": duration, "started_at": time.time(), "ended_at": None,
|
||||||
"results": {}, "flags": [], "notes": ""}
|
"k8s": server_version(), "results": {}, "flags": [], "notes": ""}
|
||||||
save_state(st)
|
save_state(st)
|
||||||
log("")
|
log("")
|
||||||
log(bold("=" * 64))
|
log(bold("=" * 64))
|
||||||
log(bold(f" {TRACK_ID.upper()} {'EXAM' if mode == 'exam' else 'DRILL'}{' · HARD' if hard else ''} - clock started"))
|
log(bold(f" {TRACK_ID.upper()} {'EXAM' if mode == 'exam' else 'DRILL'}{' · HARD' if hard else ''} - clock started"))
|
||||||
if hard:
|
log(f" Cluster : {CONTEXT} (Kubernetes {st['k8s'] or K8S}, as the real {TRACK_ID.upper()} runs {K8S})")
|
||||||
|
if hard and TRACK.get("exam_contexts"):
|
||||||
log(yellow(" Contexts: k8s-c1-H (default) · k8s-c2-AC · k8s-c3-CCC - each task tells you which to use; you are on k8s-c1-H now"))
|
log(yellow(" Contexts: k8s-c1-H (default) · k8s-c2-AC · k8s-c3-CCC - each task tells you which to use; you are on k8s-c1-H now"))
|
||||||
log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)")
|
log(f" Ends : {datetime.fromtimestamp(st['started_at'] + duration).strftime('%H:%M:%S')} ({duration // 60} min)")
|
||||||
log(f" Tasks : {', '.join('Q%02d' % q['id'] for q in qs) if mode == 'drill' else len(qs)} Pass mark: {PASS_PCT}%")
|
log(f" Tasks : {', '.join('Q%02d' % q['id'] for q in qs) if mode == 'drill' else len(qs)} Pass mark: {PASS_PCT}%")
|
||||||
@ -1105,6 +1245,9 @@ def cmd_end(args):
|
|||||||
st = require_started()
|
st = require_started()
|
||||||
if st.get("ended_at"):
|
if st.get("ended_at"):
|
||||||
die("Session already ended and recorded.")
|
die("Session already ended and recorded.")
|
||||||
|
if not kubectl_ok():
|
||||||
|
die(f"Cannot reach the {TRACK_ID.upper()} cluster ({CONTEXT}), so every check would fail and a 0% attempt would be recorded.\n"
|
||||||
|
f" Start it first: {setup_hint()} or discard the session: ./exam abort --yes")
|
||||||
if remaining(st) <= 0:
|
if remaining(st) <= 0:
|
||||||
print(yellow("Note: time expired before submission."))
|
print(yellow("Note: time expired before submission."))
|
||||||
entry = finish_session(st)
|
entry = finish_session(st)
|
||||||
@ -1131,7 +1274,7 @@ def cmd_abort(args):
|
|||||||
setup_contexts(False, {})
|
setup_contexts(False, {})
|
||||||
print(yellow("Session discarded. Nothing was graded or written to history."))
|
print(yellow("Session discarded. Nothing was graded or written to history."))
|
||||||
print(dim(f" kubectl context restored to {CONTEXT}. Seeded objects are still on the cluster;"))
|
print(dim(f" kubectl context restored to {CONTEXT}. Seeded objects are still on the cluster;"))
|
||||||
print(dim(" './exam drill ...' or './exam start' re-seeds, './teardown.sh' removes the cluster."))
|
print(dim(f" './exam drill ...' or './exam start' re-seeds, './teardown.sh {TRACK_ID}' removes the cluster."))
|
||||||
|
|
||||||
def cmd_weak(args):
|
def cmd_weak(args):
|
||||||
agg = stats(); hist = load_history()
|
agg = stats(); hist = load_history()
|
||||||
@ -1239,9 +1382,9 @@ class Api(http.server.BaseHTTPRequestHandler):
|
|||||||
def work():
|
def work():
|
||||||
try:
|
try:
|
||||||
if body.get("sim") in SETS:
|
if body.get("sim") in SETS:
|
||||||
begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True)
|
begin_session(set_questions(body["sim"]), "exam", EXAM_SECONDS, True, log=ulog, hard=True, other_ok=False)
|
||||||
elif mode == "exam":
|
elif mode == "exam":
|
||||||
begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard)
|
begin_session(compose_exam(hard, rng), "exam", EXAM_SECONDS, True, log=ulog, hard=hard, other_ok=False)
|
||||||
else:
|
else:
|
||||||
if body.get("domain") in DOMAINS:
|
if body.get("domain") in DOMAINS:
|
||||||
qs = pick(body["domain"], DRILL_SIZE, hard, rng)
|
qs = pick(body["domain"], DRILL_SIZE, hard, rng)
|
||||||
@ -1256,9 +1399,10 @@ class Api(http.server.BaseHTTPRequestHandler):
|
|||||||
qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in ids])[0], len(ids))
|
qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in ids])[0], len(ids))
|
||||||
per = DRILL_SECONDS_PER_TASK_HARD if hard else DRILL_SECONDS_PER_TASK
|
per = DRILL_SECONDS_PER_TASK_HARD if hard else DRILL_SECONDS_PER_TASK
|
||||||
begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard,
|
begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard,
|
||||||
untimed_session=bool(body.get("study")))
|
untimed_session=bool(body.get("study")), other_ok=False)
|
||||||
except SystemExit:
|
except SystemExit as e:
|
||||||
Api.seed_error = "\n".join(l for l in Api.log_lines if l.strip()) or "start failed - see the terminal running ./exam web"
|
Api.seed_error = (getattr(e, "msg", None) or "\n".join(l for l in Api.log_lines if l.strip())
|
||||||
|
or "start failed - see the terminal running ./exam web")
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
Api.seed_error = f"{type(e).__name__}: {e}"
|
Api.seed_error = f"{type(e).__name__}: {e}"
|
||||||
finally:
|
finally:
|
||||||
@ -1283,6 +1427,8 @@ class Api(http.server.BaseHTTPRequestHandler):
|
|||||||
st["results"][str(q["id"])] = grade_one(q, session_params(st, q)); save_state(st)
|
st["results"][str(q["id"])] = grade_one(q, session_params(st, q)); save_state(st)
|
||||||
elif p.path == "/api/end":
|
elif p.path == "/api/end":
|
||||||
if not st.get("ended_at"):
|
if not st.get("ended_at"):
|
||||||
|
if not kubectl_ok(): # grading an unreachable cluster would record a 0% attempt
|
||||||
|
return self._json({"error": f"Cannot reach the {TRACK_ID.upper()} cluster ({CONTEXT}). Start it with {setup_hint()}, then submit."}, 503)
|
||||||
finish_session(st, log=lambda *_: None)
|
finish_session(st, log=lambda *_: None)
|
||||||
elif p.path == "/api/reset":
|
elif p.path == "/api/reset":
|
||||||
q = q_by_id(int(body["id"])); run(str(q.get("setup", "")), params=session_params(st, q))
|
q = q_by_id(int(body["id"])); run(str(q.get("setup", "")), params=session_params(st, q))
|
||||||
@ -1301,7 +1447,8 @@ class Api(http.server.BaseHTTPRequestHandler):
|
|||||||
"solution": render(str(q.get("solution", "")), session_params(st, q)) if solution_allowed(st, int(q["id"])) else None}
|
"solution": render(str(q.get("solution", "")), session_params(st, q)) if solution_allowed(st, int(q["id"])) else None}
|
||||||
for q in qs],
|
for q in qs],
|
||||||
"bank": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "difficulty": q["difficulty"]} for q in QUESTIONS],
|
"bank": [{"id": q["id"], "domain": q["domain"], "title": q["title"], "difficulty": q["difficulty"]} for q in QUESTIONS],
|
||||||
"tracks": TRACKS, "drilled": drilled_domains(), "sets": {k: {"name": v, "count": len(set_questions(k))} for k, v in SETS.items()},
|
"tracks": TRACKS, "track_k8s": track_k8s(),
|
||||||
|
"cluster": CONTEXT, "drilled": drilled_domains(), "sets": {k: {"name": v, "count": len(set_questions(k))} for k, v in SETS.items()},
|
||||||
"seen": sorted(last_seen()),
|
"seen": sorted(last_seen()),
|
||||||
"seeding": Api.seeding, "seed_error": Api.seed_error,
|
"seeding": Api.seeding, "seed_error": Api.seed_error,
|
||||||
"session": None,
|
"session": None,
|
||||||
@ -1384,11 +1531,27 @@ def cmd_web(args):
|
|||||||
ttyd.terminate()
|
ttyd.terminate()
|
||||||
|
|
||||||
def cmd_version(args):
|
def cmd_version(args):
|
||||||
print(f"exam {VERSION} track={TRACK_ID} bank={len(QUESTIONS)} tasks python={sys.version.split()[0]}")
|
print(f"exam {VERSION} track={TRACK_ID} k8s={K8S} cluster={CONTEXT} bank={len(QUESTIONS)} tasks python={sys.version.split()[0]}")
|
||||||
|
|
||||||
|
def cmd_cluster_env(args):
|
||||||
|
"""Shell assignments for this track's kind cluster - setup.sh, teardown.sh and the tool scripts
|
||||||
|
eval this, so the track -> Kubernetes version -> cluster/image mapping lives only here."""
|
||||||
|
print(f"TRACK={TRACK_ID}\nK8S={K8S}\nCLUSTER={CLUSTER}\nNODE_IMAGE={KIND_IMAGES.get(K8S, '')}")
|
||||||
|
|
||||||
|
def cmd_clusters(args):
|
||||||
|
"""Which cluster each track runs on, and whether it is up."""
|
||||||
|
print(bold(f"{'track':<7}{'exam runs':<11}{'kind cluster':<15}{'context':<19}status"))
|
||||||
|
for t in TRACKS:
|
||||||
|
load_track(t)
|
||||||
|
ver = server_version()
|
||||||
|
status = (green(f"up, {ver}") if ver and ver.startswith(K8S + ".") else yellow(f"up, but {ver}") if ver
|
||||||
|
else red(f"not reachable ({setup_hint()})"))
|
||||||
|
print(f"{t:<7}{K8S:<11}{CLUSTER:<15}{CONTEXT:<19}{status}")
|
||||||
|
|
||||||
def cmd_help(args):
|
def cmd_help(args):
|
||||||
print(__doc__ + f"\nversion {VERSION}")
|
print(__doc__ + f"\nversion {VERSION}")
|
||||||
print(f"""usage: ./exam [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks)
|
print(f"""usage: ./exam [--track cka|ckad|cks] <command> (current track: {TRACK_ID}, {len(QUESTIONS)} tasks)
|
||||||
|
Each track runs on a kind cluster at its exam's Kubernetes version - {TRACK_ID}: {K8S} on {CONTEXT}.
|
||||||
|
|
||||||
web [--port 8080] [--shell bash] exam-style browser UI: tasks, terminal, clock, submit
|
web [--port 8080] [--shell bash] exam-style browser UI: tasks, terminal, clock, submit
|
||||||
study [DOMAIN|weak|N..] no clock, solutions unlocked, nothing recorded (--count N, default {DRILL_SIZE})
|
study [DOMAIN|weak|N..] no clock, solutions unlocked, nothing recorded (--count N, default {DRILL_SIZE})
|
||||||
@ -1400,7 +1563,7 @@ def cmd_help(args):
|
|||||||
sim {'|'.join(SETS) or '<set>'}{' ' * max(1, 20 - len('|'.join(SETS)))}fixed simulator set, exam rules, {TRACK['minutes']} min
|
sim {'|'.join(SETS) or '<set>'}{' ' * max(1, 20 - len('|'.join(SETS)))}fixed simulator set, exam rules, {TRACK['minutes']} min
|
||||||
start [--hard] full exam: {sum(QUOTA.values()) or '?'} tasks sampled by domain ({sum(QUOTA_HARD.values()) or '?'} in hard mode), {TRACK['minutes']} min.
|
start [--hard] full exam: {sum(QUOTA.values()) or '?'} tasks sampled by domain ({sum(QUOTA_HARD.values()) or '?'} in hard mode), {TRACK['minutes']} min.
|
||||||
Requires every domain drilled once first (--force skips). Hard mode adds traps,
|
Requires every domain drilled once first (--force skips). Hard mode adds traps,
|
||||||
exact-output tasks, compound failures, collateral checks and exam-style contexts.
|
exact-output tasks, compound failures{' and collateral checks' if not TRACK.get('exam_contexts') else ', collateral checks and exam-style contexts'}.
|
||||||
list tasks in this session, with current + historical results
|
list tasks in this session, with current + historical results
|
||||||
show N display task N
|
show N display task N
|
||||||
grade N|all grade one task (or all) per check - drills only (--force to peek in an exam)
|
grade N|all grade one task (or all) per check - drills only (--force to peek in an exam)
|
||||||
@ -1410,11 +1573,13 @@ def cmd_help(args):
|
|||||||
weak what you keep getting wrong, with the failing checks
|
weak what you keep getting wrong, with the failing checks
|
||||||
history every attempt, scores, and per-domain trend
|
history every attempt, scores, and per-domain trend
|
||||||
reset N re-seed the scenario for task N
|
reset N re-seed the scenario for task N
|
||||||
|
clusters each track's Kubernetes version and cluster, and whether it is up
|
||||||
""")
|
""")
|
||||||
|
|
||||||
COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade,
|
COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade,
|
||||||
"status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history,
|
"status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history,
|
||||||
"reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "study": cmd_study, "version": cmd_version, "help": cmd_help}
|
"reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "study": cmd_study, "version": cmd_version, "help": cmd_help,
|
||||||
|
"clusters": cmd_clusters, "cluster-env": cmd_cluster_env}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
cmd = ARGV[0] if ARGV else "help"
|
cmd = ARGV[0] if ARGV else "help"
|
||||||
|
|||||||
7
examrc
7
examrc
@ -2,6 +2,13 @@
|
|||||||
# and safe to source yourself: source ./examrc
|
# and safe to source yourself: source ./examrc
|
||||||
[ -f ~/.bashrc ] && . ~/.bashrc
|
[ -f ~/.bashrc ] && . ~/.bashrc
|
||||||
|
|
||||||
|
# kubectl at the Kubernetes version of the cluster your latest session ran on, ahead of your own:
|
||||||
|
# ./exam keeps .state/kubectl/current pointing at it (CKA/CKS v1.35, CKAD v1.37), as the real exams
|
||||||
|
# give you a kubectl that matches the cluster. Without ./setup.sh's download it is your own kubectl.
|
||||||
|
_cka_kubectl="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.state/kubectl/current"
|
||||||
|
case ":$PATH:" in *":$_cka_kubectl:"*) ;; *) export PATH="$_cka_kubectl:$PATH" ;; esac
|
||||||
|
unset _cka_kubectl
|
||||||
|
|
||||||
alias k=kubectl
|
alias k=kubectl
|
||||||
export do='--dry-run=client -o yaml' # k create deploy x --image=nginx $do > x.yaml
|
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 now='--force --grace-period 0' # k delete pod x $now
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
# cka-sim shell setup for fish: source ./examrc.fish
|
# cka-sim shell setup for fish: source ./examrc.fish
|
||||||
|
# kubectl matching the cluster of your latest session first on PATH (see examrc)
|
||||||
|
set -l _cka_kubectl (realpath (dirname (status filename)))/.state/kubectl/current
|
||||||
|
contains -- $_cka_kubectl $PATH; or set -gx PATH $_cka_kubectl $PATH
|
||||||
alias k kubectl
|
alias k kubectl
|
||||||
set -gx KUBE_EDITOR (test -n "$KUBE_EDITOR"; and echo $KUBE_EDITOR; or echo vi)
|
set -gx KUBE_EDITOR (test -n "$KUBE_EDITOR"; and echo $KUBE_EDITOR; or echo vi)
|
||||||
# lists, so they expand to separate arguments
|
# lists, so they expand to separate arguments
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
|
# Topology shared by every cka-sim cluster. setup.sh passes --name and the pinned --image per
|
||||||
|
# Kubernetes minor (cka-sim-135 for CKA/CKS, cka-sim-137 for CKAD - see ./exam cluster-env).
|
||||||
kind: Cluster
|
kind: Cluster
|
||||||
apiVersion: kind.x-k8s.io/v1alpha4
|
apiVersion: kind.x-k8s.io/v1alpha4
|
||||||
name: cka-sim
|
|
||||||
nodes:
|
nodes:
|
||||||
- role: control-plane
|
- role: control-plane
|
||||||
- role: worker
|
- role: worker
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Install vim/nano/less/jq on every kind node (the real exam has an editor on each node).
|
# 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.
|
# Re-run this after ./setup.sh if the install was skipped or the network was down.
|
||||||
|
# ./node-tools.sh [TRACK] the nodes of TRACK's cluster (default: the track ./exam defaults to)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
|
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
|
||||||
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
|
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
|
||||||
for n in $(kind get nodes --name cka-sim); do
|
cluster=$(./exam ${1:+--track "$1"} cluster-env | sed -n 's/^CLUSTER=//p')
|
||||||
|
for n in $(kind get nodes --name "$cluster"); do
|
||||||
printf '%s: ' "$n"
|
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 '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
|
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
|
||||||
|
|||||||
158
setup.sh
158
setup.sh
@ -1,5 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Bootstraps the cka-sim kind cluster (1 control-plane + 3 workers) on podman and preloads images.
|
# Bootstraps the kind cluster(s) for the exam tracks on podman and preloads images.
|
||||||
|
# Each exam runs on one Kubernetes minor, and each minor gets its own cluster (1 control-plane +
|
||||||
|
# 3 workers), so you practise on the version you will sit:
|
||||||
|
# ./setup.sh [cka|ckad|cks|all] [--recreate]
|
||||||
|
# cka, cks -> cka-sim-135 (Kubernetes v1.35) ckad -> cka-sim-137 (Kubernetes v1.37)
|
||||||
|
# With no track it sets up the one ./exam defaults to ($CKA_TRACK, else cka). Idempotent: an
|
||||||
|
# existing cluster is reused (its nodes restarted if stopped); --recreate rebuilds one whose
|
||||||
|
# version is wrong. `./exam cluster-env` is the single source of the track -> version -> image map.
|
||||||
# Override with CONTAINER_RUNTIME=docker if you ever want docker instead.
|
# Override with CONTAINER_RUNTIME=docker if you ever want docker instead.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
@ -14,6 +21,20 @@ need kind "https://kind.sigs.k8s.io/docs/user/quick-start/#installation"
|
|||||||
need kubectl "https://kubernetes.io/docs/tasks/tools/"
|
need kubectl "https://kubernetes.io/docs/tasks/tools/"
|
||||||
need python3 ""
|
need python3 ""
|
||||||
|
|
||||||
|
tracks=() recreate=0
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
cka|ckad|cks) tracks+=("$1") ;;
|
||||||
|
all) tracks+=(cka ckad cks) ;;
|
||||||
|
--track) tracks+=("${2:?--track needs a value}"); shift ;;
|
||||||
|
--recreate) recreate=1 ;;
|
||||||
|
-h|--help) sed -n '2,10p' "$(basename "$0")" | sed 's/^# \{0,1\}//'; exit 0 ;; # cwd is the script dir
|
||||||
|
*) echo "unknown argument '$1'. usage: ./setup.sh [cka|ckad|cks|all] [--recreate]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
[ ${#tracks[@]} -gt 0 ] || tracks=("${CKA_TRACK:-cka}")
|
||||||
|
|
||||||
if [ "$RT" = podman ]; then
|
if [ "$RT" = podman ]; then
|
||||||
export KIND_EXPERIMENTAL_PROVIDER=podman
|
export KIND_EXPERIMENTAL_PROVIDER=podman
|
||||||
podman info >/dev/null 2>&1 || { echo "ERROR: podman is not working (try: podman info)."; exit 1; }
|
podman info >/dev/null 2>&1 || { echo "ERROR: podman is not working (try: podman info)."; exit 1; }
|
||||||
@ -31,16 +52,92 @@ else
|
|||||||
docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon is not running/reachable."; exit 1; }
|
docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon is not running/reachable."; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if kind get clusters 2>/dev/null | grep -qx cka-sim; then
|
# a session in progress keeps its kubectl context and kubectl version (kind create switches the context)
|
||||||
echo "==> kind cluster 'cka-sim' already exists, reusing it."
|
prev_ctx=$(kubectl config current-context 2>/dev/null || true)
|
||||||
|
open_session=$(python3 -c 'import glob, json
|
||||||
|
for f in glob.glob(".state/state-*.json"):
|
||||||
|
try:
|
||||||
|
if not json.load(open(f)).get("ended_at"): print(f[13:-5])
|
||||||
|
except Exception: pass' | head -1)
|
||||||
|
|
||||||
|
# track -> cluster, deduplicated: CKA and CKS share the v1.35 cluster
|
||||||
|
clusters=() first_ctx=""
|
||||||
|
for t in "${tracks[@]}"; do
|
||||||
|
env_lines=$(./exam --track "$t" cluster-env) || exit 1
|
||||||
|
eval "$env_lines" # TRACK K8S CLUSTER NODE_IMAGE
|
||||||
|
[ -n "$NODE_IMAGE" ] || { echo "ERROR: no kind node image is pinned for Kubernetes $K8S (KIND_IMAGES in ./exam)."; exit 1; }
|
||||||
|
[ -n "$first_ctx" ] || first_ctx="kind-$CLUSTER"
|
||||||
|
case " ${clusters[*]-} " in *" $CLUSTER|"*) ;; *) clusters+=("$CLUSTER|$K8S|$NODE_IMAGE") ;; esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# kubectl at the cluster's own version, as the real exam gives you: .state/kubectl/<minor>/kubectl.
|
||||||
|
# ./exam points .state/kubectl/current at the right one per session and examrc puts it on PATH.
|
||||||
|
# On Linux it comes out of the control-plane node, which ships exactly that kubectl (offline, no
|
||||||
|
# checksum needed); elsewhere the node's Linux binary will not run, so it is downloaded.
|
||||||
|
fetch_kubectl() {
|
||||||
|
local ver=$1 dir=.state/kubectl/$2 cp=$3 os arch url tmp want got
|
||||||
|
if [ -x "$dir/kubectl" ] && "$dir/kubectl" version --client 2>/dev/null | grep -q "Client Version: $ver\$"; then
|
||||||
|
echo " kubectl $ver already in $dir"; return 0
|
||||||
|
fi
|
||||||
|
os=$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
|
||||||
|
mkdir -p "$dir"; tmp=$(mktemp "$dir/.kubectl.XXXXXX")
|
||||||
|
if [ "$os" = linux ] && "$RT" cp "$cp:/usr/bin/kubectl" "$tmp" >/dev/null 2>&1 && chmod +x "$tmp" \
|
||||||
|
&& "$tmp" version --client 2>/dev/null | grep -q "Client Version: $ver\$"; then
|
||||||
|
mv -f "$tmp" "$dir/kubectl"; echo " kubectl $ver (from $cp) -> $dir/kubectl"; return 0
|
||||||
|
fi
|
||||||
|
url="https://dl.k8s.io/release/$ver/bin/$os/$arch/kubectl"
|
||||||
|
want=$(curl -sfL --max-time 30 "$url.sha256" || true)
|
||||||
|
if [ -n "$want" ] && curl -sfL --max-time 300 "$url" -o "$tmp"; then
|
||||||
|
got=$( (sha256sum "$tmp" 2>/dev/null || shasum -a 256 "$tmp") | awk '{print $1}')
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
chmod +x "$tmp"; mv -f "$tmp" "$dir/kubectl"; echo " kubectl $ver -> $dir/kubectl"; return 0
|
||||||
|
fi
|
||||||
|
echo " warn: kubectl $ver checksum mismatch, discarded"
|
||||||
|
fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
echo " warn: could not download kubectl $ver - your own kubectl ($(kubectl version --client 2>/dev/null | head -1)) will be used"
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
kind create cluster --name "$1" --config kind-config.yaml --image "${KIND_NODE_IMAGE:-$2}" --wait 180s
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_cluster() {
|
||||||
|
local cluster=$1 k8s=$2 image=$3 ctx="kind-$1" cp="$1-control-plane" ver
|
||||||
|
echo
|
||||||
|
if kind get clusters 2>/dev/null | grep -qx "$cluster"; then
|
||||||
|
# rootless podman does not bring node containers back after a reboot or logout
|
||||||
|
for n in $(kind get nodes --name "$cluster"); do
|
||||||
|
[ "$("$RT" inspect -f '{{.State.Running}}' "$n" 2>/dev/null)" = true ] || { echo "==> starting stopped node $n"; "$RT" start "$n" >/dev/null; }
|
||||||
|
done
|
||||||
|
ver=$("$RT" exec "$cp" cat /kind/version 2>/dev/null || echo unknown)
|
||||||
|
case "$ver" in
|
||||||
|
"$k8s".*) echo "==> kind cluster '$cluster' already exists (Kubernetes $ver), reusing it." ;;
|
||||||
|
*)
|
||||||
|
if [ "$recreate" != 1 ]; then
|
||||||
|
echo "ERROR: kind cluster '$cluster' runs Kubernetes $ver, but its tracks need $k8s."
|
||||||
|
echo " Rebuild it (this deletes everything on it): ./setup.sh ${tracks[*]} --recreate"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> '$cluster' runs $ver, not $k8s - recreating it"
|
||||||
|
kind delete cluster --name "$cluster"
|
||||||
|
create "$cluster" "$image"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
else
|
else
|
||||||
echo "==> Creating kind cluster 'cka-sim' on $RT (1 control-plane + 3 workers)..."
|
echo "==> Creating kind cluster '$cluster' (Kubernetes $k8s) on $RT (1 control-plane + 3 workers)..."
|
||||||
kind create cluster --config kind-config.yaml ${KIND_NODE_IMAGE:+--image "$KIND_NODE_IMAGE"} --wait 180s
|
create "$cluster" "$image"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
kubectl config use-context kind-cka-sim >/dev/null
|
|
||||||
echo "==> Waiting for nodes..."
|
echo "==> Waiting for nodes..."
|
||||||
kubectl wait --for=condition=Ready nodes --all --timeout=180s
|
# `kubectl wait` gives up on a refused connection, and restarted nodes need 15-40 s before the
|
||||||
|
# API server answers again - wait for it first
|
||||||
|
local deadline=$((SECONDS + 180))
|
||||||
|
until kubectl --context "$ctx" get --raw=/readyz >/dev/null 2>&1; do
|
||||||
|
[ $SECONDS -lt $deadline ] || { echo "ERROR: the API server of '$cluster' did not come back within 180 s."; exit 1; }
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
kubectl --context "$ctx" wait --for=condition=Ready nodes --all --timeout=180s
|
||||||
|
|
||||||
echo "==> Preloading images into the cluster (non-fatal if a pull fails)..."
|
echo "==> Preloading images into the cluster (non-fatal if a pull fails)..."
|
||||||
# `kind load docker-image` shells out to the docker CLI internally no matter which
|
# `kind load docker-image` shells out to the docker CLI internally no matter which
|
||||||
@ -51,7 +148,7 @@ 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
|
if "$RT" pull -q "docker.io/library/$img" >/dev/null 2>&1; then
|
||||||
tarfile="$(mktemp)"
|
tarfile="$(mktemp)"
|
||||||
if "$RT" save -o "$tarfile" "docker.io/library/$img" >/dev/null 2>&1 \
|
if "$RT" save -o "$tarfile" "docker.io/library/$img" >/dev/null 2>&1 \
|
||||||
&& kind load image-archive --name cka-sim "$tarfile" >/dev/null 2>&1; then
|
&& kind load image-archive --name "$cluster" "$tarfile" >/dev/null 2>&1; then
|
||||||
echo " loaded $img"
|
echo " loaded $img"
|
||||||
else
|
else
|
||||||
echo " warn: could not load $img"
|
echo " warn: could not load $img"
|
||||||
@ -63,13 +160,13 @@ for img in nginx:1.25 nginx:1.27 busybox:1.36 redis:7-alpine; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
echo "==> Making sure the exam workdir exists on the control-plane node..."
|
echo "==> Making sure the exam workdir exists on the control-plane node..."
|
||||||
"$RT" exec cka-sim-control-plane mkdir -p /opt/cka
|
"$RT" exec "$cp" mkdir -p /opt/cka
|
||||||
|
|
||||||
# The real exam gives you vim (and less/jq) on every node; the kind node image is minimal, so
|
# 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.
|
# install them once here. Skip with CKA_SKIP_NODE_TOOLS=1.
|
||||||
if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then
|
if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then
|
||||||
echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..."
|
echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..."
|
||||||
for n in $(kind get nodes --name cka-sim); do
|
for n in $(kind get nodes --name "$cluster"); do
|
||||||
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then
|
if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then
|
||||||
echo " $n already has vim"
|
echo " $n already has vim"
|
||||||
continue
|
continue
|
||||||
@ -88,14 +185,45 @@ fi
|
|||||||
# copied out of the running etcd pod (that image is distroless), so install them properly.
|
# copied out of the running etcd pod (that image is distroless), so install them properly.
|
||||||
echo "==> Installing etcdctl/etcdutl on the control-plane node..."
|
echo "==> Installing etcdctl/etcdutl on the control-plane node..."
|
||||||
printf ' '
|
printf ' '
|
||||||
CONTAINER_RUNTIME="$RT" bash ./etcd-tools.sh || echo " warn: no etcd client on the control-plane node; Q02/Q45 will say so when they seed"
|
CONTAINER_RUNTIME="$RT" CKA_CP="$cp" bash ./etcd-tools.sh || echo " warn: no etcd client on $cp; Q02/Q45 will say so when they seed"
|
||||||
|
|
||||||
|
echo "==> Fetching kubectl to match the cluster..."
|
||||||
|
fetch_kubectl "$("$RT" exec "$cp" cat /kind/version)" "$k8s" "$cp"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Cluster '$cluster' ready (context $ctx). Nodes:"
|
||||||
|
kubectl --context "$ctx" get nodes -o wide
|
||||||
|
}
|
||||||
|
|
||||||
|
for c in "${clusters[@]}"; do
|
||||||
|
IFS='|' read -r cluster k8s image <<<"$c"
|
||||||
|
setup_cluster "$cluster" "$k8s" "$image"
|
||||||
|
done
|
||||||
|
|
||||||
# remember the runtime so the CLI uses the matching exec command
|
# remember the runtime so the CLI uses the matching exec command
|
||||||
mkdir -p .state && echo "$RT" > .state/runtime
|
mkdir -p .state && echo "$RT" > .state/runtime
|
||||||
|
# `kind create cluster` switches the current context to each new cluster; land on the first track's,
|
||||||
|
# with its kubectl (./exam re-points both at every session start) - unless a session is open, which
|
||||||
|
# keeps what it had
|
||||||
|
first_k8s=$(./exam --track "${tracks[0]}" cluster-env | sed -n 's/^K8S=//p')
|
||||||
|
mkdir -p .state/kubectl
|
||||||
|
if [ -n "$open_session" ]; then
|
||||||
|
[ -n "$prev_ctx" ] && kubectl config use-context "$prev_ctx" >/dev/null
|
||||||
|
echo "note: a $open_session session is still open, so your kubectl context stays $prev_ctx."
|
||||||
|
elif kubectl config use-context "$first_ctx" >/dev/null && [ -x ".state/kubectl/$first_k8s/kubectl" ]; then
|
||||||
|
ln -sfn "$first_k8s" .state/kubectl/current
|
||||||
|
else # no download: current/ -> your own kubectl, so the path examrc puts on PATH always exists
|
||||||
|
own=$(PATH=$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '/\.state/kubectl' | paste -sd: -) command -v kubectl)
|
||||||
|
mkdir -p .state/kubectl/host && ln -sfn "$(realpath "$own")" .state/kubectl/host/kubectl && ln -sfn host .state/kubectl/current
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kind get clusters 2>/dev/null | grep -qx cka-sim; then
|
||||||
|
echo
|
||||||
|
echo "note: the old single cluster 'cka-sim' (from before each exam got its own version) still exists."
|
||||||
|
echo " Nothing uses it now; free its ~4 GB with: ./teardown.sh legacy"
|
||||||
|
fi
|
||||||
|
|
||||||
chmod +x exam
|
chmod +x exam
|
||||||
echo
|
echo
|
||||||
echo "Cluster ready. Nodes:"
|
echo "Next: ./exam --track ${tracks[0]} web (browser UI) or ./exam --track ${tracks[0]} drill (5 tasks from your least-drilled domain)"
|
||||||
kubectl get nodes -o wide
|
echo " ./exam clusters which cluster and Kubernetes version each track uses"
|
||||||
echo
|
|
||||||
echo "Next: ./exam web (browser UI) or ./exam drill (5 tasks from your least-drilled domain)"
|
|
||||||
|
|||||||
42
teardown.sh
42
teardown.sh
@ -1,11 +1,47 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deletes the kind cluster and all local exam state.
|
# Deletes kind cluster(s) and the local state of the tracks that ran on them (history is kept).
|
||||||
|
# ./teardown.sh every cka-sim cluster (cka-sim-135, cka-sim-137, any older minor's, and the
|
||||||
|
# old single cka-sim) + all state
|
||||||
|
# ./teardown.sh cka|cks the v1.35 cluster - CKA and CKS share it, so both their sessions go
|
||||||
|
# ./teardown.sh ckad the v1.37 cluster and the CKAD session
|
||||||
|
# ./teardown.sh legacy only the old single cluster 'cka-sim' from before per-version clusters
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
|
RT="${CONTAINER_RUNTIME:-$(cat .state/runtime 2>/dev/null || echo podman)}"
|
||||||
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
|
[ "$RT" = podman ] && export KIND_EXPERIMENTAL_PROVIDER=podman
|
||||||
kind delete cluster --name cka-sim || true
|
|
||||||
|
# cluster of a track, or stop here: an empty name would make kind delete its default cluster "kind"
|
||||||
|
cluster_of() {
|
||||||
|
local c
|
||||||
|
c=$(./exam --track "$1" cluster-env | sed -n 's/^CLUSTER=//p') || exit 1
|
||||||
|
[ -n "$c" ] || { echo "ERROR: ./exam cluster-env gave no cluster for track $1" >&2; exit 1; }
|
||||||
|
echo "$c"
|
||||||
|
}
|
||||||
|
|
||||||
|
what="${1:-all}"
|
||||||
|
case "$what" in
|
||||||
|
all)
|
||||||
|
# every cka-sim cluster - also one left behind when an exam moved to a new minor - and nothing else
|
||||||
|
for c in $(kind get clusters 2>/dev/null | grep -E '^cka-sim(-[0-9]+)?$'); do
|
||||||
|
kind delete cluster --name "$c"
|
||||||
|
done
|
||||||
# Safe to wipe wholesale: everything under workspace/ is generated by task setup scripts and
|
# Safe to wipe wholesale: everything under workspace/ is generated by task setup scripts and
|
||||||
# nothing there is tracked (see .gitignore). Anything you want to keep, copy out first.
|
# nothing there is tracked (see .gitignore). Anything you want to keep, copy out first.
|
||||||
rm -rf .state workspace
|
rm -rf .state workspace
|
||||||
echo "cka-sim cluster and local state removed."
|
echo "All cka-sim clusters and local state removed (history kept)."
|
||||||
|
;;
|
||||||
|
legacy)
|
||||||
|
kind delete cluster --name cka-sim || true
|
||||||
|
echo "Old cluster 'cka-sim' removed."
|
||||||
|
;;
|
||||||
|
cka|ckad|cks)
|
||||||
|
cluster=$(cluster_of "$what")
|
||||||
|
kind delete cluster --name "$cluster" || true
|
||||||
|
# sessions seeded on that cluster are gone with it; other tracks' sessions and files stay
|
||||||
|
for t in cka ckad cks; do
|
||||||
|
[ "$(cluster_of "$t")" = "$cluster" ] && rm -f ".state/state-$t.json"
|
||||||
|
done
|
||||||
|
echo "Cluster '$cluster' and the state of the tracks on it removed (history kept)."
|
||||||
|
;;
|
||||||
|
*) echo "usage: ./teardown.sh [all|cka|ckad|cks|legacy]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|||||||
@ -221,7 +221,7 @@ $K -n %{ns} create deploy %{dep} --image=nginx:1.25 --dry-run=client -o yaml | $
|
|||||||
$K -n %{ns} set resources deploy %{dep} --requests=cpu=50m >/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
|
$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
|
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"
|
curl -sfL --max-time 60 "$METRICS_SERVER_URL" | $K apply -f - >/dev/null || echo "warn: could not download metrics-server manifest"
|
||||||
fi
|
fi
|
||||||
# make it broken (kind kubelets use self-signed serving certs)
|
# 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 '
|
$K -n kube-system get deploy metrics-server -o json 2>/dev/null | python3 -c '
|
||||||
|
|||||||
@ -59,7 +59,7 @@ would include the work you did on other tasks, so a restore is the last thing yo
|
|||||||
""",
|
""",
|
||||||
"setup": r"""
|
"setup": r"""
|
||||||
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/backup.db'
|
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/backup.db'
|
||||||
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with ./etcd-tools.sh, then re-seed"; exit 1; }
|
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with: CKA_CP=$CP ./etcd-tools.sh (or ./etcd-tools.sh cka), then re-seed"; exit 1; }
|
||||||
$K -n default delete cm %{cm} --ignore-not-found >/dev/null
|
$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
|
$K -n default create cm %{cm} --from-literal=proof=$(date +%s)-%{cm} >/dev/null
|
||||||
out=$(nx "$CP" '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/backup.db' 2>&1) \
|
out=$(nx "$CP" '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/backup.db' 2>&1) \
|
||||||
|
|||||||
@ -895,16 +895,19 @@ grep -Eq 'kubeadm[[:space:]]+upgrade[[:space:]]+node' "$WS/q80/worker.txt" 2>/de
|
|||||||
k version -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])' > {WS}/q80/current.txt
|
k version -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])' > {WS}/q80/current.txt
|
||||||
k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion --no-headers > {WS}/q80/kubelets.txt
|
k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion --no-headers > {WS}/q80/kubelets.txt
|
||||||
cat > {WS}/q80/steps.sh <<'EOF'
|
cat > {WS}/q80/steps.sh <<'EOF'
|
||||||
apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.34.1-1.1 && apt-mark hold kubeadm
|
sed -i 's|/v1.35/|/v1.36/|' /etc/apt/sources.list.d/kubernetes.list
|
||||||
|
apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.36.5-1.1 && apt-mark hold kubeadm
|
||||||
kubeadm upgrade plan
|
kubeadm upgrade plan
|
||||||
kubeadm upgrade apply v1.34.1 -y
|
kubeadm upgrade apply v1.36.5 -y
|
||||||
kubectl drain <control-plane> --ignore-daemonsets
|
kubectl drain <control-plane> --ignore-daemonsets
|
||||||
apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.34.1-1.1 kubectl=1.34.1-1.1 && apt-mark hold kubelet kubectl
|
apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.36.5-1.1 kubectl=1.36.5-1.1 && apt-mark hold kubelet kubectl
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl restart kubelet
|
systemctl restart kubelet
|
||||||
kubectl uncordon <control-plane>
|
kubectl uncordon <control-plane>
|
||||||
EOF
|
EOF
|
||||||
echo 'kubeadm upgrade node' > {WS}/q80/worker.txt
|
echo 'kubeadm upgrade node' > {WS}/q80/worker.txt
|
||||||
|
# The cluster runs v1.35, so the next minor is 1.36 - and pkgs.k8s.io serves one apt repo per
|
||||||
|
# minor, so the repo line has to move to v1.36 before 1.36 packages exist for apt at all.
|
||||||
# kubeadm goes first because it drives the whole thing; the control plane is upgraded by
|
# kubeadm goes first because it drives the whole thing; the control plane is upgraded by
|
||||||
# `upgrade apply`, and only then do the node binaries (kubelet/kubectl) follow, ending with
|
# `upgrade apply`, and only then do the node binaries (kubelet/kubectl) follow, ending with
|
||||||
# uncordon. Workers run `kubeadm upgrade node` - they host no control-plane static pods, so
|
# uncordon. Workers run `kubeadm upgrade node` - they host no control-plane static pods, so
|
||||||
@ -1068,8 +1071,8 @@ spec:
|
|||||||
- {type: Pods, value: 2, periodSeconds: 60}
|
- {type: Pods, value: 2, periodSeconds: 60}
|
||||||
EOF
|
EOF
|
||||||
k -n route-planner get hpa route-planner-hpa
|
k -n route-planner get hpa route-planner-hpa
|
||||||
# `kubectl autoscale` only emits CPU targets and no behavior block, so memory + behavior means
|
# `kubectl autoscale` can set a memory target (--memory=70%) but has no flag for a behavior block,
|
||||||
# writing the v2 YAML by hand. Utilisation is a percentage OF THE REQUEST, so the Deployment must
|
# so memory + behavior means writing the v2 YAML by hand (or autoscale, then kubectl edit). Utilisation is a percentage OF THE REQUEST, so the Deployment must
|
||||||
# declare a memory request or the HPA reports <unknown> and never scales.
|
# declare a memory request or the HPA reports <unknown> and never scales.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -237,8 +237,8 @@ task(
|
|||||||
cluster (all Namespaces) sorted by creation time, using `kubectl`. Make it executable.
|
cluster (all Namespaces) sorted by creation time, using `kubectl`. Make it executable.
|
||||||
2. Delete the kube-proxy Pod that runs on node `{W2}`. Write the events this caused to
|
2. Delete the kube-proxy Pod that runs on node `{W2}`. Write the events this caused to
|
||||||
`{WS}/q90/pod-deleted.log`.
|
`{WS}/q90/pod-deleted.log`.
|
||||||
3. On node `{W2}`, stop **and remove** the kube-proxy **container** with `crictl` — leave the Pod
|
3. On node `{W2}`, stop the kube-proxy **container** with `crictl stop` — leave the Pod object
|
||||||
object alone. Write the events this caused to `{WS}/q90/container-killed.log`.
|
alone. Write the events this caused to `{WS}/q90/container-killed.log`.
|
||||||
4. Which of the two actions made the Pod's `RESTARTS` counter go up? Write `pod` or `container` to
|
4. Which of the two actions made the Pod's `RESTARTS` counter go up? Write `pod` or `container` to
|
||||||
`{WS}/q90/restarts.txt`.
|
`{WS}/q90/restarts.txt`.
|
||||||
|
|
||||||
@ -283,7 +283,7 @@ k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-pr
|
|||||||
|
|
||||||
{SHELL_W2}
|
{SHELL_W2}
|
||||||
crictl ps --name kube-proxy # container id
|
crictl ps --name kube-proxy # container id
|
||||||
crictl stop <id> && crictl rm <id>
|
crictl stop <id>
|
||||||
exit
|
exit
|
||||||
k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-proxy | tail -4 > {WS}/q90/container-killed.log
|
k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-proxy | tail -4 > {WS}/q90/container-killed.log
|
||||||
# Pulled, Created, Started - same Pod, new container
|
# Pulled, Created, Started - same Pod, new container
|
||||||
@ -291,6 +291,8 @@ k -n kube-system get pod -l k8s-app=kube-proxy -o wide # RESTARTS 1
|
|||||||
echo container > {WS}/q90/restarts.txt
|
echo container > {WS}/q90/restarts.txt
|
||||||
# Deleting the Pod makes the DaemonSet create a brand-new Pod (RESTARTS 0). Killing only the
|
# Deleting the Pod makes the DaemonSet create a brand-new Pod (RESTARTS 0). Killing only the
|
||||||
# container makes the kubelet start a new container inside the SAME Pod - that is a restart.
|
# container makes the kubelet start a new container inside the SAME Pod - that is a restart.
|
||||||
|
# Do not `crictl rm` it straight away: on v1.35 the kubelet counts the restart from the exited
|
||||||
|
# container it sees, and if rm wins the race it starts a fresh attempt 0 and RESTARTS stays 0.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -20,12 +20,19 @@ TRACK = {
|
|||||||
"name": "Certified Kubernetes Administrator",
|
"name": "Certified Kubernetes Administrator",
|
||||||
"minutes": 120,
|
"minutes": 120,
|
||||||
"pass_pct": 66,
|
"pass_pct": 66,
|
||||||
|
# Kubernetes minor of the real exam environment, which picks the kind cluster (shared with CKS).
|
||||||
|
# LF FAQ / "Important Instructions: CKA and CKAD", checked 2026-10-02: "The CKA environment is
|
||||||
|
# currently running Kubernetes v1.35". The LF moves each exam on its own schedule.
|
||||||
|
"k8s": "v1.35",
|
||||||
|
# the k8s-c1-H / k8s-c2-AC / k8s-c3-CCC contexts some tasks switch to - this track owns them
|
||||||
|
"exam_contexts": True,
|
||||||
# tasks per domain drawn into a session (weights follow the exam blueprint)
|
# 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": {"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
|
"quota_hard": {"ARCH": 6, "WORK": 5, "NET": 5, "STOR": 3, "TSHOOT": 6}, # 25 tasks, hard ones preferred
|
||||||
# fixed simulator sets (./exam sim c|d) - excluded from random sampling. Sets A and B were
|
# fixed simulator sets (./exam sim c|d) - excluded from random sampling. Sets A and B were
|
||||||
# retired on 2026-09-22; their last (fixed, verified) versions are in git history.
|
# retired on 2026-09-22; their last (fixed, verified) versions are in git history.
|
||||||
"sets": {"c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"},
|
"sets": {"c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"},
|
||||||
|
# CKA_Curriculum_v1.35.pdf (cncf/curriculum) - unchanged since the February 2025 revision
|
||||||
"domains": {
|
"domains": {
|
||||||
"ARCH": "Cluster Architecture, Installation & Configuration (25%)",
|
"ARCH": "Cluster Architecture, Installation & Configuration (25%)",
|
||||||
"WORK": "Workloads & Scheduling (15%)",
|
"WORK": "Workloads & Scheduling (15%)",
|
||||||
@ -82,11 +89,11 @@ Details you need:
|
|||||||
- "ssh to the control-plane node" is: `{SHELL_CP}`
|
- "ssh to the control-plane node" is: `{SHELL_CP}`
|
||||||
- `etcdctl` and `etcdutl` are installed on that node, in `/usr/local/bin`
|
- `etcdctl` and `etcdutl` are installed on that node, in `/usr/local/bin`
|
||||||
|
|
||||||
Verify the snapshot with `etcdutl snapshot status` (or `etcdctl snapshot status`).
|
Verify the snapshot with `etcdutl snapshot status` (etcd 3.6, which v1.35 runs, removed `etcdctl snapshot status`).
|
||||||
""",
|
""",
|
||||||
"setup": r"""
|
"setup": r"""
|
||||||
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/etcd-snapshot.db'
|
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/etcd-snapshot.db'
|
||||||
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with ./etcd-tools.sh, then re-seed"; exit 1; }
|
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with: CKA_CP=$CP ./etcd-tools.sh (or ./etcd-tools.sh cka), then re-seed"; exit 1; }
|
||||||
""",
|
""",
|
||||||
"checks": [
|
"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("/opt/cka/etcd-snapshot.db exists on the control-plane node", 3, r"""nx "$CP" 'test -f /opt/cka/etcd-snapshot.db'"""),
|
||||||
|
|||||||
@ -48,7 +48,7 @@ etcdutl snapshot status /opt/cka/etcd-snapshot.db -w table
|
|||||||
# /var/lib/etcd instead, which is a hostPath, and move it on the node:
|
# /var/lib/etcd instead, which is a hostPath, and move it on the node:
|
||||||
# kubectl -n kube-system exec etcd-{CP} -- etcdctl ... snapshot save /var/lib/etcd/snap.db
|
# 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
|
# then on the node: mv /var/lib/etcd/snap.db /opt/cka/etcd-snapshot.db
|
||||||
# ./etcd-tools.sh installs etcdctl/etcdutl on the control-plane node (setup.sh already does).
|
# ./etcd-tools.sh cka installs etcdctl/etcdutl on the control-plane node (setup.sh already does).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
S[3] = """# Step 1: Create the overlay directory structure
|
S[3] = """# Step 1: Create the overlay directory structure
|
||||||
@ -141,7 +141,7 @@ EOF
|
|||||||
S[6] = """# Part A: Check certificate expiration on control plane
|
S[6] = """# Part A: Check certificate expiration on control plane
|
||||||
# {WS} is a path on your own machine, so run the remote check and redirect its
|
# {WS} is a path on your own machine, so run the remote check and redirect its
|
||||||
# output locally - don't try to write {WS} from inside the node's own shell.
|
# output locally - don't try to write {WS} from inside the node's own shell.
|
||||||
podman exec cka-sim-control-plane kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q06/apiserver-expiry.txt
|
podman exec {CP} kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q06/apiserver-expiry.txt
|
||||||
|
|
||||||
# Part B: Issue and approve CertificateSigningRequest (CSR) for user Jane
|
# Part B: Issue and approve CertificateSigningRequest (CSR) for user Jane
|
||||||
# Step 3: Generate private key and CSR
|
# Step 3: Generate private key and CSR
|
||||||
@ -227,10 +227,10 @@ EOF
|
|||||||
S[40] = """# Generate a new kubeadm join token with the specified TTL and print-join-command,
|
S[40] = """# Generate a new kubeadm join token with the specified TTL and print-join-command,
|
||||||
# writing the output straight into the required workspace file (run from the host, not the node,
|
# writing the output straight into the required workspace file (run from the host, not the node,
|
||||||
# since {WS} is a path on your machine)
|
# since {WS} is a path on your machine)
|
||||||
podman exec cka-sim-control-plane kubeadm token create --ttl %{ttl} --print-join-command > {WS}/q40/join.sh
|
podman exec {CP} kubeadm token create --ttl %{ttl} --print-join-command > {WS}/q40/join.sh
|
||||||
|
|
||||||
# Verify active tokens
|
# Verify active tokens
|
||||||
podman exec cka-sim-control-plane kubeadm token list
|
podman exec {CP} kubeadm token list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
S[41] = """# Step 1: Check Kubernetes Server version (API server)
|
S[41] = """# Step 1: Check Kubernetes Server version (API server)
|
||||||
@ -448,7 +448,7 @@ exit
|
|||||||
|
|
||||||
# Step 5: Back on your own machine now - {WS} is a path on your machine, not the node,
|
# Step 5: Back on your own machine now - {WS} is a path on your machine, not the node,
|
||||||
# so capture the expiry report from out here and redirect it into the required file
|
# so capture the expiry report from out here and redirect it into the required file
|
||||||
podman exec cka-sim-control-plane kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q65/expiry.txt
|
podman exec {CP} kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q65/expiry.txt
|
||||||
"""
|
"""
|
||||||
|
|
||||||
S[67] = """# Step 1: On the control-plane node, create a fresh bootstrap token
|
S[67] = """# Step 1: On the control-plane node, create a fresh bootstrap token
|
||||||
|
|||||||
@ -62,8 +62,8 @@ podman save --format oci-archive -o {WS}/q13/%{img}-v2.tar localhost/%{img}:v2
|
|||||||
podman run --rm localhost/%{img}:v2 > {WS}/q13/run.txt # %{greet} from %{img} v2
|
podman run --rm localhost/%{img}:v2 > {WS}/q13/run.txt # %{greet} from %{img} v2
|
||||||
# `podman save` writes docker-archive unless told otherwise; "OCI format" means
|
# `podman save` writes docker-archive unless told otherwise; "OCI format" means
|
||||||
# --format oci-archive (an index.json + oci-layout + blobs/ tarball). With docker:
|
# --format oci-archive (an index.json + oci-layout + blobs/ tarball). With docker:
|
||||||
# docker build -t ... . && docker save ... (docker-archive only) - on the exam, read which tool
|
# docker build -t ... . && docker save -o x.tar ... (Docker Engine 25+ writes an OCI layout
|
||||||
# and which format the task names. `--env`/`--build-arg` at build time would not change the
|
# alongside the legacy manifest.json) - on the exam, read which tool and which format the task names. `--env`/`--build-arg` at build time would not change the
|
||||||
# Dockerfile, which is what "change the Dockerfile" asks for.
|
# Dockerfile, which is what "change the Dockerfile" asks for.
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
@ -851,7 +851,8 @@ eq "$(j -n %{ns} deploy %{dep} "$e.name}")" %{cm} && eq "$(j -n %{ns} deploy %{d
|
|||||||
],
|
],
|
||||||
"solution": """
|
"solution": """
|
||||||
k -n %{ns} get pod # CreateContainerConfigError
|
k -n %{ns} get pod # CreateContainerConfigError
|
||||||
k -n %{ns} describe pod -l app=%{dep} | grep -A3 Events # couldn't find key log-level in ConfigMap %{ns}/%{cm}
|
k -n %{ns} events --types=Warning # couldn't find key log-level in ConfigMap %{ns}/%{cm}
|
||||||
|
# (kubectl 1.36+ describe drops Events when a selector matches several Pods - add --show-events)
|
||||||
k -n %{ns} get cm %{cm} -o yaml # the key is log_level (underscore)
|
k -n %{ns} get cm %{cm} -o yaml # the key is log_level (underscore)
|
||||||
k -n %{ns} edit deploy %{dep} # key: log-level -> key: log_level
|
k -n %{ns} edit deploy %{dep} # key: log-level -> key: log_level
|
||||||
k -n %{ns} get pod # now RunContainerError / CrashLoopBackOff
|
k -n %{ns} get pod # now RunContainerError / CrashLoopBackOff
|
||||||
|
|||||||
@ -327,7 +327,7 @@ d = str(last.get("description", "")).lower()
|
|||||||
sys.exit(0 if last.get("status") == "deployed" and last.get("chart") == "buoy-0.1.0" and "rollback to 1" in d else 1)'"""),
|
sys.exit(0 if last.get("status") == "deployed" and last.get("chart") == "buoy-0.1.0" and "rollback to 1" in d else 1)'"""),
|
||||||
],
|
],
|
||||||
solution="""
|
solution="""
|
||||||
helm -n mekong list -a # Helm 3 hides failed releases without -a (Helm 4 lists them anyway)
|
helm -n mekong list --failed # buoy-north (plain `list` shows it too; Helm 4 has no -a)
|
||||||
echo buoy-north > {WS}/q43/failed.txt
|
echo buoy-north > {WS}/q43/failed.txt
|
||||||
helm -n mekong uninstall buoy-north
|
helm -n mekong uninstall buoy-north
|
||||||
helm -n mekong get values buoy-west # region: west - the value to keep
|
helm -n mekong get values buoy-west # region: west - the value to keep
|
||||||
@ -819,6 +819,8 @@ Deployment `harbor-proxy` in Namespace `niger` takes its whole nginx configurati
|
|||||||
setup=NS + r"""
|
setup=NS + r"""
|
||||||
mkns niger; rm -rf "$WS/q51"; mkdir -p "$WS/q51"
|
mkns niger; rm -rf "$WS/q51"; mkdir -p "$WS/q51"
|
||||||
$K -n niger delete deploy harbor-proxy --ignore-not-found --wait=true >/dev/null
|
$K -n niger delete deploy harbor-proxy --ignore-not-found --wait=true >/dev/null
|
||||||
|
# a test Pod left by an interrupted earlier attempt would make `kubectl run tmp` fail "AlreadyExists"
|
||||||
|
$K -n niger delete pod tmp --ignore-not-found --wait=false >/dev/null
|
||||||
$K apply -f - >/dev/null <<'EOF'
|
$K apply -f - >/dev/null <<'EOF'
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
|
|||||||
@ -11,6 +11,12 @@ TRACK = {
|
|||||||
"name": "Certified Kubernetes Application Developer",
|
"name": "Certified Kubernetes Application Developer",
|
||||||
"minutes": 120,
|
"minutes": 120,
|
||||||
"pass_pct": 66,
|
"pass_pct": 66,
|
||||||
|
# Kubernetes minor of the real exam environment, which picks the kind cluster. LF FAQ /
|
||||||
|
# "Important Instructions: CKA and CKAD", checked 2026-10-02: "The CKAD environment is currently
|
||||||
|
# running Kubernetes v1.37" (it went there from v1.35 at the end of September 2026).
|
||||||
|
"k8s": "v1.37",
|
||||||
|
# CKAD_Curriculum_v1.37.pdf (cncf/curriculum) - the same document as v1.35, so these and every
|
||||||
|
# competency the bank covers are unchanged by the version bump
|
||||||
"domains": {
|
"domains": {
|
||||||
"DESIGN": "Application Design and Build (20%)",
|
"DESIGN": "Application Design and Build (20%)",
|
||||||
"DEPLOY": "Application Deployment (20%)",
|
"DEPLOY": "Application Deployment (20%)",
|
||||||
|
|||||||
@ -13,6 +13,11 @@ TRACK = {
|
|||||||
"name": "Certified Kubernetes Security Specialist",
|
"name": "Certified Kubernetes Security Specialist",
|
||||||
"minutes": 120,
|
"minutes": 120,
|
||||||
"pass_pct": 67,
|
"pass_pct": 67,
|
||||||
|
# Kubernetes minor of the real exam environment, which picks the kind cluster (shared with CKA).
|
||||||
|
# LF "Important Instructions: CKS", checked 2026-10-02: "The CKS environment is currently running
|
||||||
|
# Kubernetes v1.35". (cncf/curriculum still names the PDF "CKS_Curriculum v1.34".)
|
||||||
|
"k8s": "v1.35",
|
||||||
|
# CKS curriculum, unchanged since the April 2025 revision
|
||||||
"domains": {
|
"domains": {
|
||||||
"SETUP": "Cluster Setup (15%)",
|
"SETUP": "Cluster Setup (15%)",
|
||||||
"HARDEN": "Cluster Hardening (15%)",
|
"HARDEN": "Cluster Hardening (15%)",
|
||||||
@ -380,12 +385,13 @@ Create Pod `%{pod}` in Namespace `%{ns}` (image `busybox:1.36`, `sleep 86400`) t
|
|||||||
|
|
||||||
- uses ServiceAccount `%{pod}-sa` (create it),
|
- uses ServiceAccount `%{pod}-sa` (create it),
|
||||||
- does **not** get an API token mounted, set on the **Pod**, not the ServiceAccount,
|
- does **not** get an API token mounted, set on the **Pod**, not the ServiceAccount,
|
||||||
- runs with `hostNetwork`, `hostPID` and `hostIPC` all explicitly disabled,
|
- does not use the host network, PID or IPC namespaces (set `hostNetwork`, `hostPID` and
|
||||||
|
`hostIPC` to `false` - the API stores `false` as unset, so the check is that none is `true`),
|
||||||
- has both CPU and memory requests and limits set (any sane values).
|
- has both CPU and memory requests and limits set (any sane values).
|
||||||
""",
|
""",
|
||||||
"setup": NS + r"""
|
"setup": NS + r"""
|
||||||
mkns %{ns}
|
mkns %{ns}
|
||||||
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null
|
$K -n %{ns} delete pod %{pod} --ignore-not-found --wait=true --grace-period=1 >/dev/null
|
||||||
$K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null
|
$K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null
|
||||||
""",
|
""",
|
||||||
"checks": [
|
"checks": [
|
||||||
@ -393,9 +399,12 @@ $K -n %{ns} delete sa %{pod}-sa --ignore-not-found >/dev/null
|
|||||||
C("token not mounted, set on the pod spec", 3, r"""
|
C("token not mounted, set on the pod spec", 3, r"""
|
||||||
eq "$(j -n %{ns} pod %{pod} '{.spec.automountServiceAccountToken}')" false \
|
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"""),
|
&& ! $K -n %{ns} exec %{pod} -- test -f /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null"""),
|
||||||
C("host namespaces explicitly disabled", 3, r"""
|
# hostNetwork/hostPID/hostIPC are omitempty booleans: an explicit false is never stored, so the
|
||||||
|
# most a check can prove is that the Pod exists and none of them is true
|
||||||
|
C("no host network, PID or IPC namespace", 3, r"""
|
||||||
p="$K -n %{ns} get pod %{pod}"
|
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"""),
|
$p >/dev/null 2>&1 || exit 1
|
||||||
|
for f in hostNetwork hostPID hostIPC; do [ "$($p -o jsonpath="{.spec.$f}")" != true ] || exit 1; done"""),
|
||||||
C("requests and limits set for cpu and memory", 2, r"""
|
C("requests and limits set for cpu and memory", 2, r"""
|
||||||
c='{.spec.containers[0].resources'
|
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"""),
|
for f in requests.cpu requests.memory limits.cpu limits.memory; do [ -n "$(j -n %{ns} pod %{pod} "$c.$f}")" ] || exit 1; done"""),
|
||||||
@ -508,7 +517,14 @@ On the control-plane node:
|
|||||||
""",
|
""",
|
||||||
"setup": r"""
|
"setup": r"""
|
||||||
rm -rf "$WS/q9"; mkdir -p "$WS/q9"
|
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
|
# Back to "no audit trail" without bricking the API server: a previous answer may have left a hostPath
|
||||||
|
# volume of type File for the policy, so leave a do-nothing policy in its place (check 1 still fails
|
||||||
|
# until the candidate writes theirs), drop the old log so it cannot pass check 3, and edit the manifest
|
||||||
|
# through a temp file outside manifests/ - a sed -i temp file there is loaded as a second static pod.
|
||||||
|
nx "$CP" 'f=/etc/kubernetes/manifests/kube-apiserver.yaml
|
||||||
|
if grep -q -- "--audit-" $f; then t=$(mktemp /etc/kubernetes/.q9.XXXXXX) && sed "/--audit-/d" $f > $t && chmod --reference=$f $t && mv -f $t $f; fi
|
||||||
|
printf "apiVersion: audit.k8s.io/v1\nkind: Policy\nrules:\n- level: None\n" > /etc/kubernetes/audit-policy.yaml
|
||||||
|
rm -f /var/log/kubernetes/audit*.log' || true
|
||||||
""",
|
""",
|
||||||
"checks": [
|
"checks": [
|
||||||
C("audit policy file exists and omits RequestReceived", 3, r"""
|
C("audit policy file exists and omits RequestReceived", 3, r"""
|
||||||
|
|||||||
@ -180,7 +180,7 @@
|
|||||||
<div style="color:var(--muted);font-size:12px;margin:-8px 0 4px">Drill a domain (✓ = drilled at least once):</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>
|
<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>
|
<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>
|
<span id="hardDesc" style="color:var(--muted)"> — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks.</span></label>
|
||||||
<div class="opts" id="lobbyOpts">
|
<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="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="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button>
|
||||||
@ -262,7 +262,7 @@
|
|||||||
|
|
||||||
function paint() {
|
function paint() {
|
||||||
const sess = S.session, live = sess && !sess.ended_at;
|
const sess = S.session, live = sess && !sess.ended_at;
|
||||||
$('#trackName').textContent = S.track.name;
|
$('#trackName').textContent = `${S.track.name} · Kubernetes ${S.track.k8s}`;
|
||||||
$('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') : '';
|
$('#modeLabel').textContent = sess ? (sess.untimed ? 'Study' : (sess.mode === 'drill' ? 'Drill' : 'Exam')) + (sess.hard ? ' · HARD' : '') : '';
|
||||||
$('#btnEnd').classList.toggle('hidden', !live);
|
$('#btnEnd').classList.toggle('hidden', !live);
|
||||||
if (live && sess.untimed) $('#btnEnd').textContent = 'End study session';
|
if (live && sess.untimed) $('#btnEnd').textContent = 'End study session';
|
||||||
@ -309,7 +309,7 @@
|
|||||||
tw.innerHTML = S.ttyd ? `<iframe id="term" src="http://127.0.0.1:${S.ttyd_port}/" title="terminal"></iframe>` :
|
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>
|
`<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)
|
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.
|
and restart <code>./exam web</code>.<br><br>Until then, work in any terminal with kubectl pointed at <code>${S.cluster}</code>.
|
||||||
Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`;
|
Task files go under <code>${S.workspace}/</code>, relative to the cka-sim directory.</div>`;
|
||||||
}
|
}
|
||||||
tickClock();
|
tickClock();
|
||||||
@ -338,7 +338,7 @@
|
|||||||
const sess = S.session;
|
const sess = S.session;
|
||||||
const hard = $('#hardMode').checked, qn = hard ? (S.track.quota_hard || {}) : (S.track.quota || {});
|
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;
|
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.`;
|
$('#examDesc').textContent = `${total} tasks sampled across the ${Object.keys(S.track.domains).length} 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]);
|
const drilled = S.drilled || {}, undone = Object.keys(S.track.domains).filter(d => !drilled[d]);
|
||||||
$('#coverage').innerHTML = Object.keys(S.track.domains).map(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('');
|
`<span class="pill pick ${drilled[d] ? 'done' : ''}" data-domain="${d}" title="Drill ${S.track.domains[d]} — 5 tasks">${d} ${drilled[d] ? '✓' : '·'}</span>`).join('');
|
||||||
@ -357,8 +357,12 @@
|
|||||||
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
|
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
|
||||||
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
|
const fe = $('#forceExam'); if (fe) fe.onclick = e => { e.preventDefault(); start('exam'); };
|
||||||
$('#lobbyTitle').textContent = S.track.name;
|
$('#lobbyTitle').textContent = S.track.name;
|
||||||
|
// only the track that owns the k8s-c* contexts (CKA) switches between them
|
||||||
|
$('#hardDesc').textContent = ' — killer.sh-style: more tasks in the same time, no hints, traps, exact-output tasks, compound failures, collateral checks'
|
||||||
|
+ (S.track.exam_contexts ? ', exam contexts.' : '.');
|
||||||
|
// each track runs on its own cluster at its exam's Kubernetes version
|
||||||
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
|
$('#trackPick').innerHTML = (S.tracks || []).map(t =>
|
||||||
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}">${t.toUpperCase()}</span>`).join('');
|
`<span class="pill pick ${t === S.track_id ? 'sel' : ''}" data-track="${t}" title="Kubernetes ${(S.track_k8s || {})[t] || '?'}">${t.toUpperCase()} <span style="opacity:.6">${(S.track_k8s || {})[t] || ''}</span></span>`).join('');
|
||||||
$('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => {
|
$('#trackPick').querySelectorAll('[data-track]').forEach(el => el.onclick = async () => {
|
||||||
if (el.dataset.track === S.track_id) return;
|
if (el.dataset.track === S.track_id) return;
|
||||||
S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
|
S = await api('/api/track', {track: el.dataset.track}); cur = null; load();
|
||||||
@ -414,7 +418,9 @@
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
$('#confirm').classList.add('hidden');
|
$('#confirm').classList.add('hidden');
|
||||||
$('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…';
|
$('#btnEnd').disabled = true; $('#btnEnd').textContent = 'Grading…';
|
||||||
try { S = await api('/api/end', {}); } finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; }
|
try { S = await api('/api/end', {}); }
|
||||||
|
catch (e) { alert(e.message); return; } // e.g. the cluster is down - nothing was graded or recorded
|
||||||
|
finally { $('#btnEnd').disabled = false; $('#btnEnd').textContent = 'Submit exam'; }
|
||||||
showResults(); paint();
|
showResults(); paint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user