kubestronaut-sim/cka/questions.py
vumas3r 0e5f7686ee feat: add guided lessons; move CKA and CKS to v1.37 on Ubuntu hosts
Add lessons: untimed guided labs, one per curriculum objective, on a
host of their own, in the browser and in the terminal. Each step has
a check, a hint and its commands; a lesson writes no history or
scores, only a done mark on its objective. CKAD 2.1-2.4 (24 steps)
and all 27 CKA objectives (135 steps) have one, and `./ksim lesson
audit` proves that every step fails, is done by its own commands,
then passes.

Add CKA tasks 132-147 (cka/bank_course.py) on what a full CKA course
teaches beyond the bank: a real upgrade from v1.36, a drain under a
PodDisruptionBudget, QoS and eviction, in-place resize, kube-proxy's
chains, encryption at rest, an operator, and CSI expansion, snapshots
and clones (ensure_csi_hostpath).

Move CKA and CKS to Kubernetes v1.37 and rebuild the hosts on Ubuntu
24.04, with kubelet, kubeadm and kubectl as held packages from
pkgs.k8s.io; the desktop runs Ubuntu too. Hosts gain man pages,
hexdump and xxd.

Fix CKS Q4, which gave 3 of 12 points untouched, and CKA Q4 and Q49,
whose solutions raced their new CRD.

Audited on v1.37: CKA 147/147, CKAD 105/105, CKS 109/110 (Q103, a
Falco task, failed to seed once and awaits a re-audit); lessons: 135
of 135 CKA steps and 24 of 24 CKAD steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:15:17 -06:00

674 lines
35 KiB
Python

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