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
105 lines
6.1 KiB
Python
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"
|
|
}
|
|
"""
|