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>
156 lines
5.8 KiB
Python
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}
|
|
"""
|