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

156 lines
5.8 KiB
Python

"""Building blocks shared by the CKA task banks written for the per-task hosts (114 onwards): bash
for setups and checks. The generic ones - NET, PODS, PY, CAN - are sim/bank.py's.
The two rules of the CKS and CKAD banks hold here as well: a task scores nothing before anyone has
worked on it, and what can be tested for real - a file on a node, a name resolved from a Pod, a
request, a volume that gets provisioned - is tested for real rather than read off the object.
"""
from sim.bank import C, CAN, NET, NS, PODS, PY, ns_pick, name_pick # noqa: F401 (the banks import these from here)
# one FILE: the file's content with blanks and line ends removed - for a one-word answer
ONE = r"""
one() { tr -d ' \t\n\r' < "$1" 2>/dev/null; }
"""
# Volumes and claims.
STOR = ONE + r"""
# pvnode PV: the node a PersistentVolume is tied to (its node affinity)
pvnode() { j pv "$1" '{.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]}'; }
# pvpath PV: its directory on that node (hostPath or local)
pvpath() { local p; p=$(j pv "$1" '{.spec.hostPath.path}'); [ -n "$p" ] || p=$(j pv "$1" '{.spec.local.path}'); echo "$p"; }
# bound NS CLAIM [SECONDS]: the claim is Bound, given a moment
bound() {
local i
for i in $(seq 1 $(( ${3:-45} / 3 ))); do [ "$(j -n "$1" pvc "$2" '{.status.phase}')" = Bound ] && return 0; sleep 3; done
return 1
}
# running NS POD [SECONDS]: the Pod is Running, given a moment
running() {
local i
for i in $(seq 1 $(( ${3:-60} / 3 ))); do [ "$(j -n "$1" pod "$2" '{.status.phase}')" = Running ] && return 0; sleep 3; done
return 1
}
# gone KIND NAME [-n NS]: the object does not exist (and the API server said so)
gone() { local out; out=$($K get "$@" 2>&1); case "$out" in *NotFound*) return 0 ;; esac; return 1; }
# newest NS SELECTOR: the name of the newest Running Pod the selector matches
newest() { $K -n "$1" get pod -l "$2" --field-selector status.phase=Running --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}' 2>/dev/null; }
"""
# Name lookups from a Pod.
DNS = r"""
# look NS POD NAME: the address the name resolves to from that Pod ("" when it does not)
look() { $K -n "$1" exec "$2" -- nslookup "$3" 2>/dev/null | awk '/^Name:/ {n=1} n && /^Address/ {print $NF; exit}'; }
# resolves NS POD NAME ADDRESS: it resolves to that address, given a moment (CoreDNS reloads its
# configuration by itself, but not at once)
resolves() { local i; for i in $(seq 1 10); do [ "$(look "$1" "$2" "$3")" = "$4" ] && return 0; sleep 3; done; return 1; }
"""
# A small operator for Backup objects (group data.example.com): a controller that gives each
# Backup a ConfigMap and marks it Scheduled. It is kubectl in a loop - the node's own kubectl,
# mounted into a busybox container - which is enough to show the pattern: a CRD, RBAC for its
# controller, and a Deployment that acts on it. With status_rbac=False the ClusterRole lacks
# `backups/status`, and the controller can make the ConfigMaps but not set the phase.
_BACKUP_CRD = r"""apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: backups.data.example.com
spec:
group: data.example.com
scope: Namespaced
names: {kind: Backup, plural: backups, singular: backup, shortNames: [bk]}
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [schedule]
properties:
schedule: {type: string}
retention: {type: integer, minimum: 1, default: 7}
status:
type: object
properties:
phase: {type: string}
---
"""
def backup_operator(crd=False, status_rbac=True):
"""The operator's manifests, as one YAML document stream (the CRD first, when asked for)."""
status = ", backups/status" if status_rbac else ""
return (_BACKUP_CRD if crd else "") + r"""apiVersion: v1
kind: Namespace
metadata:
name: backup-operator
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: controller
namespace: backup-operator
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: backup-operator
rules:
- apiGroups: [data.example.com]
resources: [backups""" + status + r"""]
verbs: [get, list, watch, update, patch]
- apiGroups: [""]
resources: [configmaps]
verbs: [get, list, create, update, patch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: backup-operator
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: backup-operator}
subjects:
- {kind: ServiceAccount, name: controller, namespace: backup-operator}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller
namespace: backup-operator
spec:
replicas: 1
selector:
matchLabels: {app: backup-operator}
template:
metadata:
labels: {app: backup-operator}
spec:
serviceAccountName: controller
containers:
- name: controller
image: busybox:1.36
command:
- sh
- -c
- |
set -f
while true; do
for b in $(kubectl get backups -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'); do
ns=${b%/*}; name=${b#*/}
sched=$(kubectl -n "$ns" get backup "$name" -o jsonpath='{.spec.schedule}')
kubectl -n "$ns" create configmap "backup-$name" --from-literal=schedule="$sched" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
kubectl -n "$ns" patch backup "$name" --subresource=status --type=merge -p '{"status":{"phase":"Scheduled"}}' >/dev/null
done
sleep 5
done
volumeMounts:
- {name: kubectl, mountPath: /bin/kubectl, readOnly: true}
volumes:
- name: kubectl
hostPath: {path: /usr/bin/kubectl, type: File}
"""