kubestronaut-sim/cks/common.py
vumas3r f3d0e536b6 Exam simulator for CKA, CKAD and CKS with per-task hosts and a remote desktop
kubestronaut-sim runs each exam task on a host of its own - a kind (kubeadm)
cluster in rootless podman, or a QEMU/KVM Ubuntu VM for the CKS tasks that
need their own kernel (Falco, AppArmor) - and puts a browser desktop in front
of them, the way killer.sh and sailor.sh do.

- ksim, sim/: the engine (sessions, drills, study mode, grading, coverage,
  audit), host and VM lifecycle, the desktop
- images/: exam host images (common, CKS variant with its tools, VM guest)
- cka/ (131 tasks), ckad/ (105), cks/ (110): task banks, curricula with
  study notes, reference solutions and scripted solutions

Every domain of every track has at least 17 pool tasks. `./ksim audit` proves
a task three ways: it seeds cleanly on its own host, scores nothing before
anyone has worked on it, and reaches full marks when its scripted solution is
run on the host as `candidate`. All 346 tasks pass (CKA audited 2026-10-13,
CKAD and CKS 2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-13 16:29:33 -06:00

105 lines
6.1 KiB
Python

"""Building blocks shared by the CKS task banks: bash for setups and checks.
A setup or a check is bash that runs on this machine against the task's own cluster (see the
README: $K, $CP, $W1, nx, $WS, $PRIV). Prepend what it needs: "setup": API + PODS + r\"\"\"...\"\"\"
Two rules every task here follows:
- It scores nothing before anyone has worked on it. A check that something is refused, blocked
or gone only counts together with proof that the task's own work is in place - a connection
that fails, or an object that is absent, proves nothing by itself.
- What can be tested for real is tested for real: requests through NetworkPolicies, an
unauthenticated call to a kubelet, a Pod the admission chain must reject, the bytes in etcd.
The helpers every track uses - NET (traffic probes), PODS (busybox Pods to test with) and PY (an
object as JSON for a Python expression) - live in sim/bank.py and are re-exported here.
"""
from sim.bank import C, NET, NS, PODS, PY, ns_pick, name_pick # noqa: F401 (the banks import these from here)
# ---- the control plane's static Pods and the node's services -------------------------------
API = r"""
MAN=/etc/kubernetes/manifests
api_ok() { $K get --raw=/readyz >/dev/null 2>&1; }
# wait_api [SECONDS]: until the API server answers (a static Pod takes 20-60 s to come back)
wait_api() { local i; for i in $(seq 1 $(( ${1:-180} / 3 ))); do api_ok && return 0; sleep 3; done; return 1; }
# medit FILE 'YQ EXPRESSION': change a static Pod manifest on the control plane through a copy
# outside the manifests directory - an editor's temp file in there is run as a second static Pod.
# The expression may contain double quotes, no single ones.
medit() { nx "$CP" "cp $MAN/$1 /root/.ksim-$1 && yq -i '$2' /root/.ksim-$1 && cp /root/.ksim-$1 $MAN/$1 && rm -f /root/.ksim-$1" </dev/null; }
# runs PROCESS PATTERN: PROCESS (kube-apiserver, etcd, ..) runs with PATTERN on its command line
runs() { nx "$CP" "pgrep -af '^$1 ' | grep -q -- '$2'" </dev/null; }
# wait_proc PROCESS PATTERN [SECONDS]: until it does
wait_proc() { local i; for i in $(seq 1 $(( ${3:-180} / 3 ))); do runs "$1" "$2" && return 0; sleep 3; done; return 1; }
# flag COMPONENT NAME: the value of --NAME in a control-plane manifest ("" when it is not set)
flag() { nx "$CP" "grep -- '- --$2=' $MAN/$1.yaml | head -1 | sed 's/^[^=]*=//'" </dev/null; }
# pod_ready NS POD [SECONDS]: the Pod reports Ready (a mirror Pod's status lags its container)
pod_ready() { local i; for i in $(seq 1 $(( ${3:-90} / 3 ))); do [ "$(j -n "$1" pod "$2" '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && return 0; sleep 3; done; return 1; }
# node_ready NODE [SECONDS]
node_ready() { local i; for i in $(seq 1 $(( ${2:-90} / 3 ))); do [ "$(j node "$1" '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && return 0; sleep 3; done; return 1; }
# kcfg NODE 'YQ PATH': a value of the node's kubelet configuration
kcfg() { nx "$1" "yq '$2' /var/lib/kubelet/config.yaml" </dev/null; }
# bench TARGETS IDS: how many of these kube-bench checks pass on the control plane
bench() { nx "$CP" "kube-bench run --targets $1 --check $2 2>/dev/null" </dev/null | grep -c '^\[PASS\]'; }
"""
# ---- an address outside the cluster --------------------------------------------------------
EXT = r"""
# extsvc NAME ADDR FILE BODY: a web server answering http://ADDR/FILE with BODY that is outside the
# cluster as far as Pods on the worker can tell: the address sits on the control plane's loopback
# and the worker routes it there, so a Pod's request leaves its node and an egress NetworkPolicy
# applies to it - what a cloud's metadata address or a host on the internet is to a real cluster.
# Both halves are Pods of their own, so the address survives a restart of the host.
extsvc() {
local cpip; cpip=$(j node "$CP" '{.status.addresses[?(@.type=="InternalIP")].address}')
$K apply -f - >/dev/null <<EOF || return 1
apiVersion: v1
kind: Pod
metadata: {name: $1, namespace: kube-system, labels: {ksim: external}}
spec:
hostNetwork: true
nodeName: $CP
tolerations: [{operator: Exists}]
containers:
- name: httpd
image: busybox:1.36
securityContext: {capabilities: {add: [NET_ADMIN]}}
command: ["sh", "-c", "ip addr replace $2/32 dev lo; mkdir -p /www/$(dirname "$3") && echo '$4' > /www/$3 && exec httpd -f -p $2:80 -h /www"]
---
apiVersion: v1
kind: Pod
metadata: {name: $1-route, namespace: kube-system, labels: {ksim: external}}
spec:
hostNetwork: true
nodeName: $W1
tolerations: [{operator: Exists}]
containers:
- name: route
image: busybox:1.36
securityContext: {capabilities: {add: [NET_ADMIN]}}
command: ["sh", "-c", "ip route replace $2/32 via $cpip; exec sleep 2147483647"]
EOF
$K -n kube-system wait --for=condition=Ready pod/$1 pod/$1-route --timeout=180s >/dev/null
}
"""
# ---- certificates --------------------------------------------------------------------------
TLS = r"""
# selfsigned HOST KEYFILE CERTFILE [DAYS]: a self-signed certificate for HOST (CN and subjectAltName)
selfsigned() {
openssl req -x509 -newkey rsa:2048 -nodes -days "${4:-180}" -keyout "$2" -out "$3" \
-subj "/CN=$1" -addext "subjectAltName=DNS:$1" >/dev/null 2>&1
}
# crt_of NS SECRET / key_of NS SECRET: the PEM certificate / private key held by a TLS Secret
crt_of() { j -n "$1" secret "$2" '{.data.tls\.crt}' | base64 -d 2>/dev/null; }
key_of() { j -n "$1" secret "$2" '{.data.tls\.key}' | base64 -d 2>/dev/null; }
fp() { openssl x509 -noout -fingerprint -sha256 2>/dev/null; } # of a certificate on stdin
san_has() { openssl x509 -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:$1\(,\|$\)"; } # certificate on stdin names HOST
# pair CERT_PEM KEY_PEM: the private key belongs to the certificate
pair() { [ -n "$1" ] && [ -n "$2" ] && [ "$(echo "$1" | openssl x509 -noout -pubkey 2>/dev/null | sha256sum)" = "$(echo "$2" | openssl pkey -pubout 2>/dev/null | sha256sum)" ]; }
# tls_secret NS SECRET HOST: a kubernetes.io/tls Secret whose certificate names HOST and whose key fits it
tls_secret() {
[ "$(j -n "$1" secret "$2" '{.type}')" = kubernetes.io/tls ] || return 1
local c k; c=$(crt_of "$1" "$2"); k=$(key_of "$1" "$2")
echo "$c" | san_has "$3" && pair "$c" "$k"
}
"""