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

702 lines
41 KiB
Python

"""CKAD curriculum as a study path: the 24 objectives of the five domains, in the curriculum's own
words and order (CKAD_Curriculum_v1.37.pdf, cncf/curriculum), each with what to know, the fastest
commands, the traps, the documentation pages the exam lets you open, and the tasks that practise it.
./ksim objectives the path, with your progress per objective
./ksim notes 3.2 the notes for one objective (id or key: 3.2, probes)
./ksim study 3.2 those notes, then its tasks with no clock and solutions visible
./ksim drill 3.2 the same tasks against the clock
"tasks" lists every task that really exercises the objective, most direct first; a task may sit
under several objectives. Study and drill sessions use the pool tasks; the Simulator Set A tasks
(38-57) are listed too but stay out unless you ask for them (--all), so the mock stays unspoiled.
Every task must be listed at least once and every objective needs two pool tasks -
`./ksim objectives` says so when that stops being true.
"""
OBJECTIVES = [
# ------------------------------------------------- 1. Application Design and Build (20%) ----
{
"id": "1.1", "key": "images", "domain": "DESIGN",
"text": "Define, build and modify container images",
"tasks": [13, 60, 41, 77, 78],
"know": [
"Read and change a Dockerfile: FROM, COPY, RUN, ENV, ARG (build time only), WORKDIR, USER, LABEL, EXPOSE, ENTRYPOINT, CMD.",
"ENTRYPOINT is the program, CMD its default arguments. In a Pod, `command` replaces ENTRYPOINT and `args` replaces CMD.",
"Build, tag, list, inspect, run, save and remove images. The exam terminal has docker or podman - the verbs are the same.",
"A tag is only a pointer: one image can carry several tags, and rebuilding under an old tag moves it.",
],
"fast": """
podman build -t localhost/app:v1 . # -f path/Dockerfile --build-arg KEY=val
podman tag localhost/app:v1 localhost/app:latest
podman images | grep app
podman inspect localhost/app:v1 --format '{{.Config.User}} {{.Config.Entrypoint}} {{.Config.Cmd}} {{.Config.Env}}'
podman run --rm localhost/app:v1 [args replacing CMD] # -d --name x keeps it running; podman logs x
podman save --format oci-archive -o app.tar localhost/app:v1 # docker save -o app.tar ...
podman rmi localhost/app:v0
""",
"traps": [
"An ARG is gone at run time unless you copy it into an ENV (`ARG V` then `ENV APP_VERSION=$V`).",
"`podman save` writes a docker-archive unless you pass `--format oci-archive`.",
"Build before you edit when a task wants the old version kept under its own tag.",
"imagePullPolicy defaults to Always for `:latest` (or no tag) and IfNotPresent for any other tag.",
],
"docs": ["https://kubernetes.io/docs/concepts/containers/images/",
"https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/"],
},
{
"id": "1.2", "key": "workloads", "domain": "DESIGN",
"text": "Choose and use the right workload resource (Deployment, DaemonSet, CronJob, etc.)",
"tasks": [3, 14, 15, 39, 38, 44, 56, 79, 80],
"know": [
"Choose by behaviour: Deployment (interchangeable replicas, rolling updates), StatefulSet (stable name, DNS and storage per replica), DaemonSet (one Pod per node), Job (run to completion), CronJob (Jobs on a schedule), bare Pod (one-off).",
"Job: completions, parallelism, backoffLimit, activeDeadlineSeconds, ttlSecondsAfterFinished; restartPolicy must be Never or OnFailure.",
"CronJob: schedule, startingDeadlineSeconds, concurrencyPolicy (Allow, Forbid, Replace), suspend, successfulJobsHistoryLimit, failedJobsHistoryLimit.",
"DaemonSet: no replicas; a toleration for node-role.kubernetes.io/control-plane:NoSchedule puts it on control-plane nodes too.",
],
"fast": """
k create deploy web --image=nginx:1.27 --replicas=3 $do > deploy.yaml
k create job render --image=busybox:1.36 $do -- sh -c 'echo done' > job.yaml
k create cronjob digest --image=busybox:1.36 --schedule='*/5 * * * *' $do -- sh -c 'echo digest' > cj.yaml
k create job digest-manual --from=cronjob/digest # run a CronJob once, now
k run tmp --image=busybox:1.36 --restart=Never --rm -it -- sh
# DaemonSet has no generator: create a Deployment with $do, change kind, delete replicas/strategy/status
k explain cronjob.spec.jobTemplate.spec --recursive | less
""",
"traps": [
"Three nested specs in a CronJob: spec (schedule, startingDeadlineSeconds) > jobTemplate.spec (completions, activeDeadlineSeconds) > template.spec (containers, restartPolicy).",
"A Job's Pod template is immutable - delete the Job and create it again instead of editing it.",
"A StatefulSet needs `serviceName` naming a headless Service (clusterIP: None) before its Pods get their own DNS names.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/job/",
"https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/",
"https://kubernetes.io/docs/concepts/workloads/controllers/daemonset/"],
},
{
"id": "1.3", "key": "multicontainer", "domain": "DESIGN",
"text": "Understand multi-container Pod design patterns (e.g. sidecar, init and others)",
"tasks": [1, 2, 16, 40, 81, 82],
"know": [
"Containers of a Pod share its network (they reach each other on localhost) and any volume both mount - that is all a sidecar, adapter or ambassador is.",
"Init containers run one at a time, in order, each to completion, before any app container starts.",
"A native sidecar is an init container with `restartPolicy: Always`: it starts before the app containers, keeps running beside them, and does not keep a Job from completing.",
"Address one container with `-c`: `k logs POD -c NAME`, `k exec POD -c NAME -- ...`. Status `Init:0/1` means an init container is still running.",
],
"fast": """
k run app --image=nginx:1.27 $do > pod.yaml # then add the second container by hand
spec:
initContainers:
- {name: wait, image: busybox:1.36, command: [sh, -c, "until wget -qO- -T2 http://backend; do sleep 2; done"]}
- {name: shipper, image: busybox:1.36, restartPolicy: Always, command: [sh, -c, "tail -F /logs/app.log"],
volumeMounts: [{name: logs, mountPath: /logs}]} # native sidecar
containers:
- {name: app, image: nginx:1.27, volumeMounts: [{name: logs, mountPath: /var/log/app}]}
volumes: [{name: logs, emptyDir: {}}]
k logs app -c shipper; k logs app --all-containers --prefix
""",
"traps": [
"`kubectl run` creates one container only - generate the Pod and add the others in the YAML.",
"A Service with no endpoints still resolves in DNS, so an init container waiting on `nslookup` is not waiting for the app. Wait on a real request.",
"A classic sidecar that never exits keeps a Job running for ever; make it a native sidecar.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/pods/init-containers/",
"https://kubernetes.io/docs/concepts/workloads/pods/sidecar-containers/"],
},
{
"id": "1.4", "key": "volumes", "domain": "DESIGN",
"text": "Utilize persistent and ephemeral volumes",
"tasks": [17, 10, 52, 40, 50, 83, 84],
"know": [
"Ephemeral: emptyDir (lives as long as the Pod; `medium: Memory` is tmpfs, `sizeLimit` caps it), and the configMap, secret, downwardAPI and projected volumes.",
"Persistent: a PersistentVolumeClaim asks for storage (accessModes, resources.requests.storage, storageClassName); a StorageClass provisions the PersistentVolume, or a pre-made one binds.",
"Access modes: ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod.",
"volumeMounts: mountPath, readOnly, and subPath to mount one file or directory without hiding the rest of the target directory.",
],
"fast": """
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: data}
spec:
accessModes: [ReadWriteOnce]
storageClassName: standard
resources: {requests: {storage: 100Mi}}
--- # in the Pod spec:
volumes:
- {name: data, persistentVolumeClaim: {claimName: data}}
- {name: scratch, emptyDir: {medium: Memory, sizeLimit: 32Mi}}
containers:
- volumeMounts: [{name: data, mountPath: /data}, {name: scratch, mountPath: /scratch}]
k get pvc,pv; k describe pvc data # Pending? the Events say why
""",
"traps": [
"There is no `kubectl create pvc` - copy the claim from the docs.",
"With a WaitForFirstConsumer StorageClass (`standard` here) a claim stays Pending until a Pod uses it. That is not an error.",
"emptyDir survives a container restart but not a new Pod. To prove data persists, delete the Pod and read the file again.",
"A non-root container cannot write to a fresh volume unless the Pod sets `securityContext.fsGroup`.",
],
"docs": ["https://kubernetes.io/docs/concepts/storage/persistent-volumes/",
"https://kubernetes.io/docs/concepts/storage/volumes/",
"https://kubernetes.io/docs/tutorials/configuration/configure-persistent-volume-storage/"],
},
# ------------------------------------------------------- 2. Application Deployment (20%) ----
{
"id": "2.1", "key": "strategies", "domain": "DEPLOY",
"text": "Use Kubernetes primitives to implement common deployment strategies (e.g. blue/green or canary)",
"tasks": [5, 18, 85, 86],
"know": [
"Blue/green: two Deployments, and the Service selector decides which one gets all the traffic. Cut over by changing the selector; roll back by changing it back.",
"Canary: two Deployments whose Pods share the label the Service selects. Traffic splits by the number of ready Pods: 1 canary beside 4 stable is 20%.",
"A Deployment's own strategy is RollingUpdate (default) or Recreate (every old Pod stops first - downtime).",
],
"fast": """
k get svc web -o wide; k get pods --show-labels
k set selector svc web 'app=web,version=green' # blue/green cut-over
k scale deploy web-stable --replicas=4 # canary ratio: 4 stable + 1 canary = 20%
k get endpointslices -l kubernetes.io/service-name=web -o wide
k get pods -l app=web -L version,track
""",
"traps": [
"Wait until the new Deployment is fully available before you switch the selector.",
"`kubectl set selector` replaces the whole selector - name every label you want in it.",
"A canary only shares traffic if the Service selector leaves out the label that tells the two versions apart.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/management/",
"https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"],
},
{
"id": "2.2", "key": "rollouts", "domain": "DEPLOY",
"text": "Understand Deployments and how to perform rolling updates",
"tasks": [4, 19, 42, 44, 87, 88, 89],
"know": [
"Any change to `spec.template` starts a rollout: a new ReplicaSet scales up while the old one scales down, within maxSurge and maxUnavailable (25% each by default).",
"minReadySeconds (how long a Pod must be Ready to count as available), revisionHistoryLimit (old ReplicaSets kept), progressDeadlineSeconds.",
"`kubectl rollout`: status, history, undo, pause, resume, restart.",
],
"fast": """
k set image deploy/web nginx=nginx:1.27 # CONTAINER=IMAGE
k rollout status deploy/web
k rollout history deploy/web # --revision=3 shows that revision's template
k rollout undo deploy/web # --to-revision=2
k rollout restart deploy/web # same spec, new Pods
k annotate deploy/web kubernetes.io/change-cause='pick 1.27'
k patch deploy web -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}'
""",
"traps": [
"`--record` is deprecated: write the kubernetes.io/change-cause annotation yourself.",
"An undo makes a new revision out of the old one, so the active revision number is not the one you rolled back to.",
"A rollout stuck on a bad image does not fail by itself - `rollout status` just waits, and the old Pods keep serving.",
"maxSurge and maxUnavailable cannot both be 0.",
],
"docs": ["https://kubernetes.io/docs/concepts/workloads/controllers/deployment/"],
},
{
"id": "2.3", "key": "helm", "domain": "DEPLOY",
"text": "Use the Helm package manager to deploy existing packages",
"tasks": [6, 21, 43, 90, 91, 92],
"know": [
"A chart is the package, a release is one installation of it in a Namespace, and every install, upgrade or rollback adds a revision.",
"Values: the chart's defaults (`helm show values`), overridden with `-f file.yaml` and `--set key=value`; `helm get values` shows what a release was given.",
"Life cycle: repo add/update, search, install, upgrade, rollback, uninstall, list, history, status.",
],
"fast": """
helm repo add NAME URL && helm repo update; helm search repo nginx --versions
helm show values CHART > values.yaml
helm install REL CHART -n NS --create-namespace -f values.yaml --set replicaCount=2 # --version X
helm upgrade REL CHART -n NS --reuse-values --set replicaCount=4
helm list -n NS # -A for every Namespace; --failed, --pending filter by status
helm history REL -n NS; helm rollback REL 1 -n NS; helm uninstall REL -n NS
helm get values REL -n NS # --all adds the defaults; helm get manifest REL -n NS
""",
"traps": [
"Releases live in a Namespace: without `-n` Helm looks in `default` and finds nothing.",
"An upgrade without `--reuse-values` (or the same `-f`/`--set` again) puts every value back to the chart's default.",
"A rollback is a new revision, not a step back in the numbering.",
"Helm 3's `helm list` leaves out pending releases unless you add `-a`; Helm 4 (on this machine) lists every status and has no `-a`. `--pending` and `--failed` work in both.",
],
"docs": ["https://helm.sh/docs/intro/using_helm/", "https://helm.sh/docs/helm/helm_upgrade/"],
},
{
"id": "2.4", "key": "kustomize", "domain": "DEPLOY",
"text": "Kustomize",
"tasks": [20, 61, 45, 93],
"know": [
"A kustomization.yaml lists `resources` (files or other kustomizations) and transforms them: namespace, namePrefix, nameSuffix, labels, images, replicas, patches.",
"Generators build ConfigMaps and Secrets from files, env files or literals, add a content hash to the name and rewrite every reference to it.",
"Base and overlays: an overlay lists `../../base` under resources and changes only what differs.",
"`kubectl kustomize DIR` prints the result; `kubectl apply -k DIR` applies it.",
],
"fast": """
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: [../../base] # or files: [deployment.yaml, service.yaml]
namespace: prod
namePrefix: prod-
labels: [{pairs: {env: prod}, includeSelectors: false}]
images: [{name: nginx, newTag: "1.27"}]
replicas: [{name: web, count: 3}]
patches: [{path: patch.yaml}] # a partial Deployment with the same name
configMapGenerator: [{name: settings, envs: [settings.env]}]
secretGenerator: [{name: token, literals: [token=abc]}]
generatorOptions: {disableNameSuffixHash: true}
k kustomize DIR | less; k apply -k DIR; k diff -k DIR
""",
"traps": [
"Always read the render before you apply it.",
"`images`, `replicas` and patches name things as the base does, before any prefix or suffix.",
"Refer to a generated ConfigMap by its plain name - Kustomize appends the hash everywhere it is used.",
"`namespace:` does not create the Namespace.",
],
"docs": ["https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/"],
},
# --------------------------------- 3. Application Observability and Maintenance (15%) ----
{
"id": "3.1", "key": "deprecations", "domain": "OBS",
"text": "Understand API deprecations",
"tasks": [22, 58],
"know": [
"Every object is a kind in an API group at a version (apps/v1 Deployment). Versions are deprecated, keep being served for some releases, then are removed.",
"What this cluster serves: `kubectl api-resources` (the APIVERSION column), `kubectl api-versions`, `kubectl explain KIND`.",
"A removed version fails with `no matches for kind \"X\" in version \"g/v\"`; a deprecated one still works and prints `Warning: ... is deprecated`.",
"Migrating is the new apiVersion plus whatever changed shape: an Ingress backend becomes `service: {name, port: {number}}` with a required pathType, and an apps/v1 Deployment needs `spec.selector`.",
],
"fast": """
k api-resources | grep -i -E 'cronjob|ingress' # NAME SHORTNAMES APIVERSION NAMESPACED KIND
k api-versions | grep batch
k explain hpa | head -4 # GROUP, KIND, VERSION
k apply -f old.yaml --dry-run=server # the error or warning, without changing anything
# removed, and what replaced them:
# extensions/v1beta1, networking.k8s.io/v1beta1 Ingress -> networking.k8s.io/v1 (1.22)
# extensions/v1beta1, apps/v1beta1, apps/v1beta2 Deployment -> apps/v1 (1.16)
# batch/v1beta1 CronJob -> batch/v1 (1.25)
# policy/v1beta1 PodDisruptionBudget -> policy/v1 (1.25)
# autoscaling/v2beta1, v2beta2 HorizontalPodAutoscaler -> autoscaling/v2 (1.25, 1.26)
""",
"traps": [
"The `kubectl convert` plugin is usually not installed - know the Ingress and Deployment rewrites by hand.",
"`kubectl get` shows an object in the API's preferred version whatever version it was written with.",
"A warning is not an error: the object was created. Read stderr.",
],
"docs": ["https://kubernetes.io/docs/reference/using-api/deprecation-guide/",
"https://kubernetes.io/docs/reference/deprecation-policy/"],
},
{
"id": "3.2", "key": "probes", "domain": "OBS",
"text": "Implement probes and health checks",
"tasks": [7, 62, 46, 94, 95],
"know": [
"Liveness: failing restarts the container. Readiness: failing takes the Pod out of Service endpoints, no restart. Startup: holds the other two off until it has passed once.",
"Handlers: httpGet (path, port), tcpSocket (port), exec (command), grpc (port).",
"Timing: initialDelaySeconds (0), periodSeconds (10), timeoutSeconds (1), failureThreshold (3), successThreshold (1). A startup probe allows failureThreshold x periodSeconds to come up.",
],
"fast": """
containers:
- name: app
startupProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5, failureThreshold: 12} # 60 s to start
readinessProbe: {httpGet: {path: /ready, port: 8080}, periodSeconds: 5, failureThreshold: 1}
livenessProbe: {tcpSocket: {port: 80}, initialDelaySeconds: 15, periodSeconds: 10}
# exec: readinessProbe: {exec: {command: [cat, /tmp/ready]}, periodSeconds: 5}
k describe pod POD | grep -E 'Liveness|Readiness|Startup'
k get events --field-selector reason=Unhealthy
k explain pod.spec.containers.livenessProbe
""",
"traps": [
"No kubectl flag adds a probe: generate the YAML and edit it, or `kubectl edit`.",
"Probes belong to a container, not to the Pod spec.",
"Restarts climbing with `Unhealthy` and `Killing` events but nothing wrong in the logs: the liveness probe is wrong or too impatient, not the app.",
"Running, 0/1 Ready and no restarts: the readiness probe is failing.",
],
"docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/",
"https://kubernetes.io/docs/concepts/workloads/pods/probes/"],
},
{
"id": "3.3", "key": "monitoring", "domain": "OBS",
"text": "Use built-in CLI tools to monitor Kubernetes applications",
"tasks": [23, 63, 48, 38, 96],
"know": [
"`kubectl get` output: -o wide, yaml, json, name, jsonpath=, custom-columns=; narrowed with -l (labels), --field-selector, -n / -A; ordered with --sort-by.",
"`kubectl top pod|node` shows live CPU and memory from metrics-server; `--containers` splits a Pod up.",
"`kubectl describe` for the story of one object, `kubectl get events` for the Namespace.",
],
"fast": """
k top pod -A --sort-by=memory; k top pod --containers; k top node
k get pods -o wide --sort-by=.spec.nodeName
k get pods -o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGE:.spec.containers[0].image
k get pod POD -o jsonpath='{.status.phase}'
k get pods -l tier=web --field-selector=status.phase!=Running -o name
k get pods -A --sort-by='.status.containerStatuses[0].restartCount'
k get events --sort-by=.lastTimestamp; k events --types=Warning --for pod/POD
""",
"traps": [
"`kubectl get --sort-by` sorts ascending (the largest is last); `kubectl top --sort-by` sorts descending (the largest is first).",
"`kubectl top` shows usage, not requests or limits, and needs about a minute after a Pod starts.",
"Quote jsonpath for the shell: '{.items[*].metadata.name}'.",
],
"docs": ["https://kubernetes.io/docs/reference/kubectl/quick-reference/",
"https://kubernetes.io/docs/reference/kubectl/jsonpath/"],
},
{
"id": "3.4", "key": "logs", "domain": "OBS",
"text": "Utilize container logs",
"tasks": [24, 64, 49, 54, 39, 97, 98],
"know": [
"`kubectl logs` shows what a container wrote to stdout and stderr. The kubelet keeps it on the node, and it goes when the Pod is deleted.",
"Pick the container with -c (init containers too), or take --all-containers; --previous is the run before the last restart.",
"Narrow with --tail=N, --since=5m, --timestamps; follow with -f; by label with -l.",
],
"fast": """
k logs POD -c CONTAINER # --all-containers --prefix
k logs POD --previous # the crashed run
k logs POD --tail=20 --since=10m --timestamps
k logs -l app=web --prefix --tail=5 # every Pod with the label
k logs deploy/web; k logs job/render # one Pod of it
k logs POD | grep ERROR > errors.log
""",
"traps": [
"With -l, only the last 10 lines of each Pod are shown unless you set --tail (--tail=-1 for everything).",
"`kubectl logs deploy/x` reads one Pod, not all replicas.",
"--previous fails when the container has never restarted.",
],
"docs": ["https://kubernetes.io/docs/concepts/cluster-administration/logging/",
"https://kubernetes.io/docs/reference/kubectl/quick-reference/"],
},
{
"id": "3.5", "key": "debugging", "domain": "OBS",
"text": "Debugging in Kubernetes",
"tasks": [8, 25, 26, 36, 47, 48, 99, 98, 96],
"know": [
"Always the same order: get (status), describe (Events at the bottom), logs (--previous), then exec or debug.",
"Pending: nothing can schedule it (resources, taints, nodeSelector, an unbound claim). ImagePullBackOff / ErrImagePull: wrong image or tag. CreateContainerConfigError: a ConfigMap, Secret or key it references is missing.",
"CrashLoopBackOff: the process exits - read the previous log and lastState.terminated (reason, exitCode). OOMKilled (137): the memory limit is too low.",
"A Deployment with no Pods at all: describe its ReplicaSet - a quota, a LimitRange or Pod Security admission is rejecting them.",
],
"fast": """
k get pods -o wide; k describe pod POD | tail -20
k get pod POD -o jsonpath='{.status.containerStatuses[0].state.waiting.reason}'
k get pod POD -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}' # .exitCode
k logs POD --previous; k exec -it POD -- sh
k debug -it POD --image=busybox:1.36 --target=CONTAINER # ephemeral container, sees its processes
k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://SVC:PORT
k describe rs -l app=web --show-events | tail; k get events --sort-by=.lastTimestamp
k get pod POD -o yaml > pod.yaml && vi pod.yaml && k replace --force -f pod.yaml
""",
"traps": [
"Most of a running Pod is immutable (the image is not): fix a bare Pod with `replace --force`, fix a Deployment's Pods in the Deployment.",
"Fix what the task names and nothing else - graders check that the ConfigMap, Service or Namespace you were told to leave is unchanged.",
"After fixing a Deployment wait for the rollout; old Pods linger in Terminating.",
"`kubectl describe` with a selector that matches several objects leaves their Events out - add `--show-events`, or describe one by name.",
],
"docs": ["https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/",
"https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/",
"https://kubernetes.io/docs/tasks/debug/debug-application/determine-reason-pod-failure/"],
},
# -------------- 4. Application Environment, Configuration and Security (25%) ----
{
"id": "4.1", "key": "crds", "domain": "ENV",
"text": "Discover and use resources that extend Kubernetes (CRD, Operators)",
"tasks": [27, 59, 58],
"know": [
"A CustomResourceDefinition adds a kind to the API; its objects (custom resources) work with every kubectl verb. An operator is a CRD plus a controller acting on those objects.",
"Discover: `k get crd`, `k api-resources --api-group=GROUP` (kind, short names, namespaced or not), `k explain KIND.spec`.",
"The CRD's schema validates custom resources: required fields, enum values, minimum and maximum. The controller reports back in `.status`.",
],
"fast": """
k get crd | grep -i backup # the CRD's name is PLURAL.GROUP
k api-resources --api-group=data.example.com
k explain backup.spec # --recursive for the whole tree
k get backups -A; k get bkp NAME -o yaml; k describe backup NAME
k get crd backups.data.example.com -o yaml | less # served versions, schema, enum, maximum
k apply -f cr.yaml --dry-run=server # every validation error, nothing created
""",
"traps": [
"A custom resource's apiVersion is the CRD's GROUP/VERSION, never apiextensions.k8s.io/v1.",
"Field names come from the CRD's schema - check with `kubectl explain` instead of guessing.",
"Deleting a CRD deletes every object of that kind.",
],
"docs": ["https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/",
"https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/",
"https://kubernetes.io/docs/concepts/extend-kubernetes/operator/"],
},
{
"id": "4.2", "key": "access", "domain": "ENV",
"text": "Understand authentication, authorization and admission control",
"tasks": [28, 29, 49, 53, 100],
"know": [
"Every request passes authentication (who is it: a user certificate or token, a ServiceAccount token), authorization (may they: RBAC), then admission control (change or reject it: LimitRanger, ResourceQuota, PodSecurity).",
"RBAC: a Role or ClusterRole lists verbs on resources (and subresources such as pods/log, deployments/scale); a RoleBinding or ClusterRoleBinding grants it to users, groups or ServiceAccounts.",
"Pod Security admission: Namespace labels pod-security.kubernetes.io/enforce|warn|audit = privileged | baseline | restricted.",
"`Forbidden: User ... cannot list` is authorization. `violates PodSecurity`, `exceeded quota` and `maximum memory usage` are admission.",
],
"fast": """
k create role ci --verb=get,list,watch --resource=pods,pods/log -n NS
k create role ci --verb=get,update,patch --resource=deployments.apps,deployments.apps/scale -n NS
k create rolebinding ci --role=ci --serviceaccount=NS:SA -n NS # --user=jane --clusterrole=view
k auth can-i list pods -n NS --as=system:serviceaccount:NS:SA
k auth can-i --list -n NS --as=system:serviceaccount:NS:SA
k get ns NS --show-labels
# what `restricted` wants from every container:
securityContext: {allowPrivilegeEscalation: false, runAsNonRoot: true, capabilities: {drop: [ALL]},
seccompProfile: {type: RuntimeDefault}}
""",
"traps": [
"The same ServiceAccount is `--serviceaccount=NS:NAME` in a binding and `system:serviceaccount:NS:NAME` after `--as`.",
"`can-i create pods/exec` asks about a Pod named exec; use `can-i create pods --subresource=exec`.",
"Admission rejects Pods, not Deployments: the Deployment exists and shows 0 Pods, and the reason is in its ReplicaSet's events.",
],
"docs": ["https://kubernetes.io/docs/reference/access-authn-authz/rbac/",
"https://kubernetes.io/docs/concepts/security/pod-security-admission/",
"https://kubernetes.io/docs/concepts/security/pod-security-standards/"],
},
{
"id": "4.3", "key": "quotas", "domain": "ENV",
"text": "Understand requests, limits, quotas",
"tasks": [30, 26, 53, 101],
"know": [
"Requests are what the scheduler reserves on a node. Limits are the ceiling: CPU above it is throttled, memory above it gets the container OOMKilled.",
"A ResourceQuota caps a Namespace's totals (requests.cpu, limits.memory, pods, count/...). Once it covers CPU or memory, every new Pod must state them.",
"A LimitRange gives containers a default request and limit and enforces a min and max per container, at admission.",
],
"fast": """
k create quota compute-quota -n NS --hard=requests.cpu=1,requests.memory=1Gi,limits.cpu=2,limits.memory=2Gi,pods=6
k describe quota -n NS # Used against Hard
k describe limitrange -n NS
apiVersion: v1
kind: LimitRange
metadata: {name: container-defaults}
spec:
limits:
- type: Container
defaultRequest: {cpu: 100m, memory: 128Mi} # the request a container gets
default: {cpu: 250m, memory: 256Mi} # the LIMIT a container gets
max: {cpu: 500m, memory: 512Mi}
""",
"traps": [
"`default` is the default limit and `defaultRequest` the default request - they are easy to swap.",
"Quotas and LimitRanges only act on Pods created afterwards.",
"The rejection is not on the Deployment: `k describe rs` shows `exceeded quota` or `maximum memory usage per Container`.",
],
"docs": ["https://kubernetes.io/docs/concepts/policy/resource-quotas/",
"https://kubernetes.io/docs/concepts/policy/limit-range/"],
},
{
"id": "4.4", "key": "configmaps", "domain": "ENV",
"text": "Understand ConfigMaps",
"tasks": [32, 10, 25, 51, 45, 102],
"know": [
"Create from literals, files, directories or env files. Each key becomes an environment variable (configMapKeyRef, envFrom) or a file (a configMap volume; `items` picks keys).",
"Mounted files follow a changed ConfigMap after a short delay. Environment variables and subPath mounts never do - restart the Pods.",
"A Pod that references a missing ConfigMap or key waits in CreateContainerConfigError unless the reference is `optional: true`.",
],
"fast": """
k create cm app --from-literal=TIER=backend --from-file=app.properties # --from-file=KEY=path --from-env-file=x.env
env:
- {name: APP_TIER, valueFrom: {configMapKeyRef: {name: app, key: TIER}}}
envFrom: [{configMapRef: {name: app}}]
volumes: [{name: cfg, configMap: {name: app, items: [{key: app.properties, path: app.properties}]}}]
volumeMounts: [{name: cfg, mountPath: /etc/app}] # one file into a full directory: add subPath: app.properties
k set env deploy/web --from=configmap/app; k exec POD -- env | grep TIER
k rollout restart deploy/web # pick up a change
""",
"traps": [
"`--from-file` makes one key named after the file; `--from-env-file` makes one key per KEY=VALUE line.",
"A volume mounted on a directory hides what the image had there.",
"`immutable: true` cannot be edited or undone - delete the ConfigMap and create it again.",
],
"docs": ["https://kubernetes.io/docs/concepts/configuration/configmap/",
"https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/"],
},
{
"id": "4.5", "key": "resources", "domain": "ENV",
"text": "Define resource requirements",
"tasks": [31, 26, 15, 53],
"know": [
"Per container: resources.requests and resources.limits for cpu (cores or millicores: 250m) and memory (Mi, Gi).",
"QoS class: Guaranteed (every container has requests equal to limits for both CPU and memory), Burstable (some are set), BestEffort (none). BestEffort is evicted first.",
"A container with a limit but no request gets a request equal to the limit.",
],
"fast": """
k set resources deploy/web -c app --requests=cpu=250m,memory=128Mi --limits=cpu=250m,memory=128Mi
resources:
requests: {cpu: 100m, memory: 64Mi}
limits: {cpu: 250m, memory: 128Mi}
k get pod POD -o jsonpath='{.status.qosClass}'
k describe node NODE | grep -A8 'Allocated resources'
""",
"traps": [
"`kubectl run` has no --requests or --limits any more: generate the YAML, or use `kubectl set resources` on a workload.",
"Guaranteed needs every container of the Pod, sidecars included.",
"Memory is Mi, not m: `128m` is a fraction of a byte.",
"A request no node can fit leaves the Pod Pending with `Insufficient cpu` or `Insufficient memory`.",
],
"docs": ["https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/",
"https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/"],
},
{
"id": "4.6", "key": "secrets", "domain": "ENV",
"text": "Create & consume Secrets",
"tasks": [33, 10, 37, 50, 105],
"know": [
"A Secret's `data` is base64 - an encoding, not encryption. `stringData` takes plain text when you write one.",
"Types: Opaque (generic), kubernetes.io/tls, kubernetes.io/dockerconfigjson, kubernetes.io/service-account-token.",
"Consumed like a ConfigMap: secretKeyRef, envFrom secretRef, or a secret volume (`secretName`, `defaultMode`, `items`).",
],
"fast": """
k create secret generic api --from-literal=username=svc --from-literal=password='S3cr3t!'
k create secret tls web-tls --cert=tls.crt --key=tls.key
k create secret docker-registry regcred --docker-server=REG --docker-username=U --docker-password=P
k get secret api -o jsonpath='{.data.password}' | base64 -d
env: [{name: API_USER, valueFrom: {secretKeyRef: {name: api, key: username}}}]
volumes: [{name: creds, secret: {secretName: api, defaultMode: 0400}}]
volumeMounts: [{name: creds, mountPath: /etc/creds, readOnly: true}]
""",
"traps": [
"A secret volume names it with `secretName`; a configMap volume uses `name`.",
"Encoding by hand: `echo -n value | base64`, or the newline becomes part of the secret.",
"defaultMode is octal in YAML (0400) and shows as decimal in JSON (256).",
],
"docs": ["https://kubernetes.io/docs/concepts/configuration/secret/",
"https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/"],
},
{
"id": "4.7", "key": "serviceaccounts", "domain": "ENV",
"text": "Understand ServiceAccounts",
"tasks": [34, 28, 10, 49, 104],
"know": [
"Every Pod runs as a ServiceAccount (`default` if you name none); its short-lived token is mounted at /var/run/secrets/kubernetes.io/serviceaccount.",
"Choose it with spec.serviceAccountName. Stop the mount with `automountServiceAccountToken: false` on the ServiceAccount or on the Pod (the Pod wins).",
"Tokens: `kubectl create token` for a short-lived one; a Secret of type kubernetes.io/service-account-token annotated with the account's name for a long-lived one.",
"A ServiceAccount can do nothing until RBAC binds a Role to it.",
],
"fast": """
k create sa ci -n NS
k set serviceaccount deploy/web ci -n NS # = spec.template.spec.serviceAccountName
k patch sa ci -n NS -p '{"automountServiceAccountToken": false}'
k create token ci -n NS --duration=1h
k auth can-i list pods -n NS --as=system:serviceaccount:NS:ci
apiVersion: v1
kind: Secret
metadata: {name: ci-token, annotations: {kubernetes.io/service-account.name: ci}}
type: kubernetes.io/service-account-token
""",
"traps": [
"No token Secret is made for a new ServiceAccount (since 1.24) - create it yourself when a task wants one.",
"serviceAccountName cannot be changed on a running Pod; on a Deployment it starts a rollout.",
"A Pod naming a ServiceAccount that does not exist is never created - see the ReplicaSet's events.",
],
"docs": ["https://kubernetes.io/docs/concepts/security/service-accounts/",
"https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/"],
},
{
"id": "4.8", "key": "security", "domain": "ENV",
"text": "Understand Application Security (SecurityContexts, Capabilities, etc.)",
"tasks": [9, 29, 44, 52, 103],
"know": [
"Pod level (spec.securityContext): runAsUser, runAsGroup, runAsNonRoot, fsGroup, supplementalGroups, seccompProfile - for every container.",
"Container level adds allowPrivilegeEscalation, privileged, readOnlyRootFilesystem and capabilities, and overrides the Pod level.",
"Capabilities are written without the CAP_ prefix: NET_BIND_SERVICE, NET_ADMIN, SYS_TIME. Drop ALL, then add back what the app needs.",
],
"fast": """
spec:
securityContext: {runAsUser: 1000, runAsGroup: 1000, runAsNonRoot: true, fsGroup: 2000}
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: [ALL], add: [NET_BIND_SERVICE]}
k exec POD -- id
k explain pod.spec.securityContext; k explain pod.spec.containers.securityContext
""",
"traps": [
"capabilities exist only on a container, fsGroup only on the Pod.",
"runAsNonRoot with an image that runs as root and no runAsUser: CreateContainerConfigError.",
"readOnlyRootFilesystem breaks an app that writes to /tmp or /var/cache - mount an emptyDir there.",
],
"docs": ["https://kubernetes.io/docs/tasks/configure-pod-container/security-context/"],
},
# -------------------------------------------------- 5. Services and Networking (20%) ----
{
"id": "5.1", "key": "netpol", "domain": "NET",
"text": "Demonstrate basic understanding of NetworkPolicies",
"tasks": [12, 35, 55, 65, 66, 67, 68, 69],
"know": [
"Pods accept and send everything until a NetworkPolicy selects them. From then on, for the policyTypes it lists, only what some policy allows gets through.",
"Policies only allow, and they add up: there is no deny rule and no ordering.",
"Peers: podSelector (same Namespace), namespaceSelector, ipBlock. Two selectors in one list item mean AND; two list items mean OR.",
"Default deny: `podSelector: {}` with a policyType and no rules. Locking down egress also needs DNS allowed (UDP and TCP 53).",
],
"fast": """
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-allow, namespace: NS}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from:
- podSelector: {matchLabels: {app: api}} # OR
- namespaceSelector: {matchLabels: {purpose: monitoring}}
ports: [{protocol: TCP, port: 5432}]
# egress that keeps DNS: egress: [{ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]}, ...]
k describe netpol db-allow -n NS
k exec CLIENT -- wget -qO- -T2 http://db:5432 # test it with real traffic
""",
"traps": [
"There is no `kubectl create networkpolicy` - copy the skeleton from the docs.",
"`- podSelector: ...` followed by `namespaceSelector:` in the same item is AND; a second `- namespaceSelector:` is OR.",
"A missing or empty `ingress` denies everything; `ingress: [{}]` allows everything.",
"The policy lives in the Namespace of the Pods it selects, and a Namespace is matched by its labels (kubernetes.io/metadata.name=NAME always exists).",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/network-policies/"],
},
{
"id": "5.2", "key": "services", "domain": "NET",
"text": "Provide and troubleshoot access to applications via services",
"tasks": [11, 36, 54, 56, 70, 71, 72, 73, 74],
"know": [
"A Service selects Pods by label and forwards `port` to `targetPort` (a number, or the name of a container port).",
"Types: ClusterIP (inside the cluster), NodePort (30000-32767 on every node), LoadBalancer, ExternalName; headless (`clusterIP: None`) gives each Pod its own DNS record.",
"DNS names: SVC, SVC.NS, SVC.NS.svc.cluster.local.",
"Troubleshoot in this order: are there endpoints, does the selector match the Pod labels, are the Pods Ready, is targetPort the port the container listens on.",
],
"fast": """
k expose deploy web --port=8080 --target-port=80 # --name=web-svc --type=NodePort
k create svc nodeport web-ext --tcp=80:80 --node-port=30080
k get svc,endpointslices -o wide; k describe svc web # Endpoints: <none> = nothing selected
k get pods -l app=web --show-labels
k run tmp --rm -it --restart=Never --image=busybox:1.36 -- wget -qO- -T3 http://web:8080
k port-forward svc/web 8080:80
""",
"traps": [
"`kubectl expose` copies the workload's selector; `kubectl create svc` selects app=<service name>, which may match nothing.",
"`expose` cannot choose the nodePort - use `create svc nodeport --node-port` or edit the YAML.",
"A Service without endpoints still resolves in DNS; the connection is what fails.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/service/",
"https://kubernetes.io/docs/tasks/debug/debug-application/debug-service/",
"https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/"],
},
{
"id": "5.3", "key": "ingress", "domain": "NET",
"text": "Use Ingress rules to expose applications",
"tasks": [37, 11, 57, 75, 76],
"know": [
"An Ingress routes HTTP(S) by host and path to Services of its own Namespace; an ingress controller, chosen with ingressClassName, does the work.",
"Each path has a pathType (Prefix, Exact or ImplementationSpecific) and a backend: service.name plus service.port.number or .name.",
"TLS: spec.tls lists hosts and the kubernetes.io/tls Secret that holds their certificate.",
],
"fast": """
k create ingress shop --class=nginx \\
--rule="shop.example.com/api*=api:8080,tls=shop-tls" --rule="shop.example.com/*=web:80,tls=shop-tls"
k create secret tls shop-tls --cert=tls.crt --key=tls.key
k get ingress,ingressclass; k describe ingress shop # a missing backend shows as <error: services "x" not found>
k explain ingress.spec.rules.http.paths
""",
"traps": [
"In --rule a trailing * means pathType Prefix; without it the path is Exact.",
"The backend port is the Service's port, not the container's.",
"No ingress controller runs in this simulator, so Ingress tasks are graded on the object - on the exam, curl it.",
],
"docs": ["https://kubernetes.io/docs/concepts/services-networking/ingress/"],
},
]