kubestronaut-sim/sim/envs.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

428 lines
20 KiB
Python

"""One environment per question.
Every task gets its own kubeadm cluster: a kind cluster whose control-plane node is the host the
task tells you to `ssh` to, as the real exams do since 2025. Nothing is shared between tasks, so a
task may break its kubelet, roll etcd back or lock the API server down without touching any other
task - and resetting a task is deleting its cluster and making it again.
Graders stay on this machine: each environment has its own kubeconfig, and its nodes are reached
with `podman exec`. A task's files live in /opt/course on its host, which is a directory of this
machine mounted into the control-plane node, so a grader reads them as plain files.
"""
import os
import re
import shutil
import subprocess
import time
RUNTIME = "podman"
NETWORK = "ksim" # podman network shared by every exam host and the desktop
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_DIR = os.path.join(ROOT, ".state")
ENV_DIR = os.path.join(STATE_DIR, "envs")
SSH_DIR = os.path.join(STATE_DIR, "ssh")
COURSE = "/opt/course" # a task's files, as its host sees them
# A host is <track><question id>: cka0037. kind names the cluster after it, so its nodes are
# cka0037-control-plane, cka0037-worker, ... Only names of this shape are ever deleted.
HOST_RE = re.compile(r"^(cka|ckad|cks)\d{4}$")
# The kind node image each exam host image is built from, one per Kubernetes minor an exam runs.
# Pinned by digest: kind only guarantees an image built for its own release (these are v0.33.0's).
BASE_IMAGES = {
"v1.35": "docker.io/kindest/node:v1.35.8@sha256:07b2536e30b803ed61d1677a79df6115f798ce64c80f9e22f6ed45afd09323c0",
"v1.37": "docker.io/kindest/node:v1.37.0@sha256:a1ed56cfb0e7b93589bdf97c8cd566405a265939e3620fc4f5de89adff580ae5",
}
HELM_VERSION = "v4.3.0"
DESKTOP_IMAGE = "localhost/ksim-desktop:latest"
# Pull-through cache for Docker Hub that every exam host's containerd uses as a mirror
# (images/node/rootfs/etc/containerd/certs.d). Its storage is a named volume, so it outlives
# `./ksim down`.
REGISTRY = "ksim-registry"
REGISTRY_IMAGE = "docker.io/library/registry:2"
# Memory a node costs once its cluster is up and seeded, for the check before a session is
# provisioned. Measured on a 20-task CKA mock (20 control planes, 30 workers): 8 GB in all, about
# 320 MB and 60 MB a node; these leave room for the tasks' own Pods.
MB_CONTROL_PLANE, MB_WORKER = 400, 100
# Exam hosts that are virtual machines (see images/vm): for tasks that need a kernel of their own -
# Falco's eBPF probe, AppArmor. One Ubuntu VM per task, run by QEMU in a container that carries
# the host's name. The base disk is built once per Kubernetes minor from the Ubuntu cloud image.
VM_IMAGE = "localhost/ksim-vm:latest"
VM_DIR = os.path.join(STATE_DIR, "vm")
UBUNTU_IMAGE = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
MB_VM = 2300 # what QEMU is given (2 GB) and its own overhead
class EnvError(Exception):
pass
def _env():
return dict(os.environ, KIND_EXPERIMENTAL_PROVIDER=RUNTIME, KIND_EXPERIMENTAL_PODMAN_NETWORK=NETWORK)
def sh(args, stdin=None, timeout=600):
"""Run a command; (returncode, combined output). Never raises on a non-zero exit."""
try:
p = subprocess.run(args, input=stdin, capture_output=True, text=True, timeout=timeout, env=_env(),
stdin=None if stdin is not None else subprocess.DEVNULL)
except subprocess.TimeoutExpired:
return 124, f"timed out after {timeout}s: {' '.join(args[:4])}"
return p.returncode, (p.stdout + p.stderr).strip()
# ----------------------------------------------------------- images ----
def node_image(k8s, variant=None):
"""The exam host image of a Kubernetes minor; `variant` names a track's own flavour of it
(images/node-<variant>, built on top of the common one) - CKS adds its tools that way."""
return f"localhost/ksim-node:{k8s}" + (f"-{variant}" if variant else "")
def image_exists(name):
return sh([RUNTIME, "image", "exists", name])[0] == 0
def build_node_image(k8s, log=print):
base = BASE_IMAGES.get(k8s)
if not base:
raise EnvError(f"no kind node image is pinned for Kubernetes {k8s} (BASE_IMAGES in sim/envs.py)")
log(f" {node_image(k8s)}: from {base.split('@')[0]}")
# etcdctl/etcdutl come out of the etcd image this kubeadm would deploy, so they match the server
rc, out = sh([RUNTIME, "run", "--rm", "--entrypoint", "sh", base, "-c",
'kubeadm config images list --kubernetes-version "$(cat /kind/version)" 2>/dev/null | grep /etcd:'])
etcd = out.strip().splitlines()[-1] if rc == 0 and out.strip() else ""
if not etcd:
raise EnvError(f"could not read the etcd image of {base}:\n{out[-400:]}")
rc, out = sh([RUNTIME, "build", "-t", node_image(k8s), "--build-arg", f"BASE={base}", "--build-arg", f"ETCD_IMAGE={etcd}",
"--build-arg", f"HELM_VERSION={HELM_VERSION}", os.path.join(ROOT, "images", "node")], timeout=1800)
if rc != 0:
raise EnvError(f"building {node_image(k8s)} failed:\n{out[-1500:]}")
log(f" {node_image(k8s)}: built (etcd tools from {etcd})")
def build_variant_image(k8s, variant, log=print):
"""A track's flavour of the exam host image, built FROM the common one (which must exist)."""
ctx = os.path.join(ROOT, "images", f"node-{variant}")
if not os.path.isdir(ctx):
raise EnvError(f"no image directory for the '{variant}' exam host (images/node-{variant})")
rc, out = sh([RUNTIME, "run", "--rm", "--entrypoint", "cat", node_image(k8s), "/kind/version"])
version = out.strip().splitlines()[-1].lstrip("v") if rc == 0 and out.strip() else ""
if not re.match(r"^\d+\.\d+\.\d+$", version):
raise EnvError(f"could not read the Kubernetes version of {node_image(k8s)}:\n{out[-300:]}")
name = node_image(k8s, variant)
log(f" {name}: from {node_image(k8s)} (Kubernetes {version} as packages, {variant.upper()} tools)")
rc, out = sh([RUNTIME, "build", "-t", name, "--build-arg", f"BASE={node_image(k8s)}",
"--build-arg", f"K8S_VERSION={version}", "--build-arg", f"K8S_MINOR={k8s}", ctx], timeout=3600)
if rc != 0:
raise EnvError(f"building {name} failed:\n{out[-1500:]}")
log(f" {name}: built")
def vm_base(k8s):
"""The disk every VM exam host of a Kubernetes minor starts from."""
return os.path.join(VM_DIR, f"base-{k8s}.qcow2")
def vm_ready(k8s):
"""VM hosts can be made: the base disk is there (the runner image is checked when one is made)."""
return os.path.exists(vm_base(k8s))
def build_vm_image(k8s, log=print):
"""The VM runner image, and the base disk: the Ubuntu cloud image booted once with
images/vm/guest/provision.sh as its first-boot script. Ten minutes or so, and about 4 GB."""
m = re.search(r":v([\d.]+)", BASE_IMAGES.get(k8s, ""))
if not m:
raise EnvError(f"no Kubernetes version is pinned for {k8s} (BASE_IMAGES in sim/envs.py)")
if not os.path.exists("/dev/kvm") or not os.access("/dev/kvm", os.R_OK | os.W_OK):
raise EnvError("VM exam hosts need KVM: /dev/kvm is missing or not accessible to you")
log(f" {VM_IMAGE}: QEMU, to run exam hosts that have to be real machines")
rc, out = sh([RUNTIME, "build", "-t", VM_IMAGE, os.path.join(ROOT, "images", "vm")], timeout=1800)
if rc != 0:
raise EnvError(f"building {VM_IMAGE} failed:\n{out[-1500:]}")
os.makedirs(VM_DIR, exist_ok=True)
cloud = os.path.join(VM_DIR, "noble.img")
if not os.path.exists(cloud):
log(f" downloading the Ubuntu 24.04 cloud image (about 600 MB)")
rc, out = sh(["curl", "-fsSL", "-o", cloud + ".part", UBUNTU_IMAGE], timeout=3600)
if rc != 0:
raise EnvError(f"could not download {UBUNTU_IMAGE}:\n{out[-300:]}")
os.replace(cloud + ".part", cloud)
build = os.path.join(VM_DIR, "build")
shutil.rmtree(build, ignore_errors=True)
shutil.copytree(os.path.join(ROOT, "images", "vm", "guest"), build)
with open(os.path.join(build, "env"), "w") as f:
f.write(f"K8S_VERSION={m.group(1)}\nK8S_MINOR={k8s}\n")
ensure_network()
ensure_registry()
log(f" {os.path.relpath(vm_base(k8s), ROOT)}: Ubuntu 24.04, Kubernetes {m.group(1)}, Falco, AppArmor - provisioning in a VM, about ten minutes")
rc, out = sh([RUNTIME, "run", "--rm", "--name", "ksim-vm-build", "--network", NETWORK, "--device", "/dev/kvm",
"--security-opt", "label=disable", "-e", f"KSIM_BASE={os.path.basename(vm_base(k8s))}",
"-v", f"{VM_DIR}:/base", "-v", f"{build}:/build:ro", VM_IMAGE, "build"], timeout=3000)
shutil.rmtree(build, ignore_errors=True)
if rc != 0 or not os.path.exists(vm_base(k8s)):
raise EnvError(f"building the VM base disk failed (the VM's console is in {os.path.join(VM_DIR, 'build.log')}):\n{out[-1500:]}")
log(f" {os.path.relpath(vm_base(k8s), ROOT)}: built ({os.path.getsize(vm_base(k8s)) / 2**30:.1f} GB)")
def build_desktop_image(log=print):
log(f" {DESKTOP_IMAGE}: XFCE, terminal, Firefox, noVNC")
rc, out = sh([RUNTIME, "build", "-t", DESKTOP_IMAGE, os.path.join(ROOT, "images", "desktop")], timeout=1800)
if rc != 0:
raise EnvError(f"building {DESKTOP_IMAGE} failed:\n{out[-1500:]}")
log(f" {DESKTOP_IMAGE}: built")
if not image_exists(REGISTRY_IMAGE):
rc, out = sh([RUNTIME, "pull", "-q", REGISTRY_IMAGE], timeout=600)
log(f" {REGISTRY_IMAGE}: " + ("pulled (the Docker Hub cache)" if rc == 0 else f"could not be pulled - hosts will pull from Docker Hub directly\n{out[-200:]}"))
# --------------------------------------------------- network and keys ----
def ensure_network():
if sh([RUNTIME, "network", "exists", NETWORK])[0] != 0:
rc, out = sh([RUNTIME, "network", "create", NETWORK])
if rc != 0 and "already exists" not in out:
raise EnvError(f"could not create the podman network '{NETWORK}': {out}")
def ensure_registry():
"""The Docker Hub cache, running. Without it hosts still pull - straight from Docker Hub."""
ensure_network()
rc, out = sh([RUNTIME, "inspect", "-f", "{{.State.Running}}", REGISTRY])
if rc == 0 and out.strip() == "true":
return True
if rc == 0:
return sh([RUNTIME, "start", REGISTRY])[0] == 0
rc, out = sh([RUNTIME, "run", "-d", "--name", REGISTRY, "--network", NETWORK, "-v", f"{REGISTRY}:/var/lib/registry",
"-e", "REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io", REGISTRY_IMAGE], timeout=300)
return rc == 0
def stop_registry():
sh([RUNTIME, "rm", "-f", REGISTRY])
def ensure_keys():
"""The key pair the desktop logs in to every exam host with. Returns (private, public) text."""
key = os.path.join(SSH_DIR, "id_ed25519")
if not os.path.exists(key):
os.makedirs(SSH_DIR, mode=0o700, exist_ok=True)
rc, out = sh(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "ksim", "-f", key])
if rc != 0:
raise EnvError(f"ssh-keygen failed: {out}")
with open(key) as f, open(key + ".pub") as g:
return f.read(), g.read().strip()
# ------------------------------------------------------------- names ----
def host_of(track_id, qid):
return f"{track_id}{int(qid):04d}"
def node_names(host, workers):
"""CP/W1/W2/W3 -> node (and container) name. Workers the host does not have map to ''."""
names = {"CP": f"{host}-control-plane"}
for i in (1, 2, 3):
names[f"W{i}"] = (f"{host}-worker" + ("" if i == 1 else str(i))) if i <= workers else ""
return names
def env_dir(host):
return os.path.join(ENV_DIR, host)
def kubeconfig(host):
return os.path.join(env_dir(host), "kubeconfig")
def course_dir(host):
return os.path.join(env_dir(host), "course")
def memory_mb(workers, vm=False):
return MB_VM if vm else MB_CONTROL_PLANE + MB_WORKER * workers
def is_vm(host):
"""The host is a virtual machine (create_vm leaves a marker in its directory)."""
return os.path.exists(os.path.join(env_dir(host), "vm"))
def available_mb():
try:
with open("/proc/meminfo") as f:
for line in f:
if line.startswith("MemAvailable:"):
return int(line.split()[1]) // 1024
except OSError:
pass
return None
# --------------------------------------------------------- lifecycle ----
def existing():
"""Exam hosts that exist right now: the clusters kind knows, and the VM hosts' containers."""
rc, out = sh(["kind", "get", "clusters"])
hosts = {n for n in out.split() if HOST_RE.match(n)} if rc == 0 else set()
rc, out = sh([RUNTIME, "ps", "-a", "--filter", "label=ksim.vm", "--format", '{{index .Labels "ksim.vm"}}'])
if rc == 0:
hosts |= {n for n in out.split() if HOST_RE.match(n)}
return sorted(hosts)
def running(host):
rc, out = sh([RUNTIME, "inspect", "-f", "{{.State.Running}}", f"{host}-control-plane"])
return rc == 0 and out.strip() == "true"
def start(host):
"""Start the stopped nodes of a host - rootless podman does not bring them back after a reboot."""
if is_vm(host): # the VM boots from its disk again: same machine, same cluster
return sh([RUNTIME, "start", f"{host}-control-plane"])[0] == 0
rc, out = sh(["kind", "get", "nodes", "--name", host])
nodes = [n for n in out.split() if n.startswith(host + "-")] if rc == 0 else []
for n in sorted(nodes, key=lambda n: "control-plane" not in n):
sh([RUNTIME, "start", n])
return bool(nodes)
def node_exec(node, script, timeout=120):
"""Run a bash script as root on a node. For a VM host the node's container only runs QEMU:
the script goes on into the machine over ssh."""
into = ["ssh", "vm", "bash -s"] if is_vm(node.rsplit("-control-plane", 1)[0]) else ["bash", "-s"]
return sh([RUNTIME, "exec", "-i", node, *into], stdin=script, timeout=timeout)
_ACCESS = r"""
set -e
install -d -m700 -o candidate -g candidate /home/candidate/.ssh
install -d -m700 /root/.ssh
cat > /home/candidate/.ssh/authorized_keys <<'KSIM'
%(pub)s
KSIM
cat > /home/candidate/.ssh/id_ed25519 <<'KSIM'
%(priv)sKSIM
cp /home/candidate/.ssh/authorized_keys /home/candidate/.ssh/id_ed25519 /root/.ssh/
chown candidate:candidate /home/candidate/.ssh/authorized_keys /home/candidate/.ssh/id_ed25519
chmod 600 /home/candidate/.ssh/* /root/.ssh/*
if [ -f /etc/kubernetes/admin.conf ]; then
install -d -o candidate -g candidate /home/candidate/.kube
install -m600 -o candidate -g candidate /etc/kubernetes/admin.conf /home/candidate/.kube/config
install -d /root/.kube
install -m600 /etc/kubernetes/admin.conf /root/.kube/config
fi
systemctl is-active --quiet ssh || systemctl start ssh
"""
def _why(out):
"""The lines of a failed `kind create` that say what went wrong, without kubeadm's stack trace."""
lines = [l.strip() for l in out.splitlines() if l.strip() and not l.startswith(("\t", " "))
and not re.match(r"^(github\.com|k8s\.io|sigs\.k8s\.io|runtime\.|main\.|Stack Trace)", l)]
keep = [l for l in lines if re.search(r"error|fail|timed out|unable|cannot|refused|no such", l, re.I)]
return "\n".join((keep or lines)[-6:])[-900:]
def create(host, image, workers=0):
"""Make the cluster of one question and open it to `ssh` from the desktop. Idempotent only in
the sense that a leftover cluster of the same name is replaced."""
if not HOST_RE.match(host):
raise EnvError(f"'{host}' is not an exam host name")
priv, pub = ensure_keys()
ensure_network()
destroy(host)
os.makedirs(course_dir(host), exist_ok=True)
cfg = os.path.join(env_dir(host), "kind.yaml")
with open(cfg, "w") as f:
f.write("kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n"
" - role: control-plane\n extraMounts:\n"
f" - hostPath: {course_dir(host)}\n containerPath: {COURSE}\n"
+ " - role: worker\n" * workers)
out = ""
for attempt in (1, 2):
rc, out = sh(["kind", "create", "cluster", "--name", host, "--image", image, "--config", cfg,
"--kubeconfig", kubeconfig(host), "--wait", "240s"], timeout=600)
if rc == 0:
break
sh(["kind", "delete", "cluster", "--name", host, "--kubeconfig", kubeconfig(host)])
if attempt == 2:
raise EnvError(f"kind could not create {host}:\n{_why(out)}")
names = node_names(host, workers)
for node in [n for n in names.values() if n]:
rc, out = node_exec(node, _ACCESS % {"pub": pub, "priv": priv})
if rc != 0:
raise EnvError(f"could not open {node} to ssh:\n{out[-400:]}")
return names
def create_vm(host, k8s):
"""A VM exam host: a single-node kubeadm cluster on Ubuntu, in a virtual machine of its own.
Returns its node names, as create() does."""
if not HOST_RE.match(host):
raise EnvError(f"'{host}' is not an exam host name")
if not vm_ready(k8s) or not image_exists(VM_IMAGE):
raise EnvError("the VM exam host is not built yet: ./ksim build cks --vm")
ensure_keys()
ensure_network()
destroy(host)
os.makedirs(course_dir(host), exist_ok=True)
open(os.path.join(env_dir(host), "vm"), "w").close()
node = f"{host}-control-plane"
rc, out = sh([RUNTIME, "run", "-d", "--name", node, "--hostname", node, "--network", NETWORK, "--device", "/dev/kvm",
"--security-opt", "label=disable", "--label", f"ksim.vm={host}", "-p", "127.0.0.1::6443",
"-e", f"KSIM_HOSTNAME={node}", "-e", f"KSIM_BASE={os.path.basename(vm_base(k8s))}",
"-v", f"{VM_DIR}:/base:ro", "-v", f"{env_dir(host)}:/env", "-v", f"{course_dir(host)}:/course",
"-v", f"{SSH_DIR}:/keys:ro", VM_IMAGE, "run"])
if rc != 0:
raise EnvError(f"could not start the VM of {host}:\n{out[-400:]}")
def console():
try:
with open(os.path.join(env_dir(host), "console.log"), errors="replace") as f:
return "".join(f.readlines()[-12:])
except OSError:
return ""
deadline = time.time() + 480 # boot, then kubeadm init on the first boot: usually about 90 s
while True:
if sh([RUNTIME, "exec", node, "ssh", "vm", "test -f /var/lib/ksim-ready"], timeout=30)[0] == 0:
break
if not running(host):
raise EnvError(f"the VM of {host} stopped while starting:\n{console()}")
if time.time() > deadline:
raise EnvError(f"the VM of {host} did not get its cluster up in 8 minutes:\n{console()}")
time.sleep(3)
# the graders' kubeconfig: the VM's API server, through the port its container publishes
rc, port = sh([RUNTIME, "port", node, "6443/tcp"])
rc2, conf = sh([RUNTIME, "exec", node, "ssh", "vm", "cat /etc/kubernetes/admin.conf"], timeout=30)
if rc != 0 or rc2 != 0 or "server:" not in conf:
raise EnvError(f"could not read the kubeconfig of {host}:\n{(port + conf)[-300:]}")
with open(os.open(kubeconfig(host), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as f:
f.write(re.sub(r"server: https://\S+", f"server: https://{port.strip().splitlines()[0]}", conf) + "\n")
return node_names(host, 0)
def hand_over(host, namespace=None):
"""After seeding: the task's files belong to `candidate`, and the host's kubeconfig starts in
`namespace` when the task says to work in "the current namespace"."""
script = f"chown -R candidate:candidate {COURSE}\n"
if namespace:
script += (f"for kc in /home/candidate/.kube/config /root/.kube/config; do\n"
f" kubectl --kubeconfig $kc config set-context --current --namespace={namespace} >/dev/null\ndone\n")
return node_exec(f"{host}-control-plane", script)
def destroy(host):
if not HOST_RE.match(host):
raise EnvError(f"refusing to delete '{host}': not an exam host name")
sh(["kind", "delete", "cluster", "--name", host, "--kubeconfig", kubeconfig(host)], timeout=180)
# a VM host is one container that kind knows nothing about; only one that carries this host's
# label is removed
rc, out = sh([RUNTIME, "inspect", "-f", '{{index .Config.Labels "ksim.vm"}}', f"{host}-control-plane"])
if rc == 0 and out.strip() == host:
sh([RUNTIME, "rm", "-f", "-t", "3", f"{host}-control-plane"], timeout=60)
d = env_dir(host)
if os.path.isdir(d):
# files written by `candidate` on the host belong to a sub-uid here; remove them as the
# user namespace's root
sh([RUNTIME, "unshare", "rm", "-rf", d])
shutil.rmtree(d, ignore_errors=True)