"""Simulator Set C - 18 tasks, fixed set, modelled on a commercial CKA mock exam sitting. Original scenarios (not copies of any commercial simulator) exercising the same techniques as a typical 18-question mock: hostPath PV/PVC binding by volumeName, data migration between claims, Helm render-then-install, Gateway API header routing, RBAC to a file, probe/selector/targetPort repair, PSA-free securityContext work, HPA behavior blocks, and "write the answer to a file" data-extraction tasks. Two scenarios are deliberately adapted so they cannot wreck the practice cluster: - 77 audits the installed CNI instead of installing Calico over kindnet. - 80 plans a control-plane upgrade instead of running `kubeadm upgrade apply` for real. Run with: ./ksim sim c """ from sim.bank import C, NS Q = [] def task(**kw): kw.setdefault("difficulty", "hard") kw["set"] = "c" Q.append(kw) # ------------------------------------------------------------------ 68 ---- task( id=68, domain="STOR", title="Provision geo-fence persistent storage", task=""" In Namespace `geo-fence`: 1. Create a PersistentVolume `geofence-pv`: capacity 250Mi, accessMode ReadWriteOnce, hostPath `/data/geofence`, storageClassName `manual`, reclaim policy `Retain`. 2. Create a PersistentVolumeClaim `geofence-pvc` in `geo-fence`: 250Mi, ReadWriteOnce, storageClassName `manual`, bound to **that specific PV by name**. 3. Create Pod `geofence-reader` in `geo-fence` using `busybox:1.36`, mounting the claim at `/geo-data`, running `sh -c "ls /geo-data && sleep 3600"`. The claim must end up `Bound` to `geofence-pv` and the Pod must reach Running. """, setup=NS + r""" mkns geo-fence $K -n geo-fence delete pod geofence-reader --ignore-not-found --wait=true >/dev/null 2>&1 $K -n geo-fence delete pvc geofence-pvc --ignore-not-found --wait=true >/dev/null 2>&1 $K delete pv geofence-pv --ignore-not-found --wait=true >/dev/null 2>&1 for n in "$CP" "$W1" "$W2" "$W3"; do nx "$n" 'mkdir -p /data/geofence' /dev/null)" 250Mi \ && eq "$($p -o jsonpath='{.spec.accessModes[0]}')" ReadWriteOnce \ && eq "$($p -o jsonpath='{.spec.hostPath.path}')" /data/geofence \ && eq "$($p -o jsonpath='{.spec.storageClassName}')" manual \ && eq "$($p -o jsonpath='{.spec.persistentVolumeReclaimPolicy}')" Retain"""), C("PVC geofence-pvc is Bound to geofence-pv", 3, r""" eq "$(j -n geo-fence pvc geofence-pvc '{.status.phase}')" Bound \ && eq "$(j -n geo-fence pvc geofence-pvc '{.spec.volumeName}')" geofence-pv"""), C("pod geofence-reader Running with the claim mounted at /geo-data", 2, r""" for i in $(seq 1 20); do [ "$(j -n geo-fence pod geofence-reader '{.status.phase}')" = Running ] && break; sleep 3; done eq "$(j -n geo-fence pod geofence-reader '{.status.phase}')" Running \ && eq "$(j -n geo-fence pod geofence-reader '{.spec.volumes[0].persistentVolumeClaim.claimName}')" geofence-pvc \ && j -n geo-fence pod geofence-reader '{.spec.containers[0].volumeMounts[*].mountPath}' | grep -q /geo-data"""), ], solution=""" cat < "$WS/q69/chart/Chart.yaml" <<'EOF' apiVersion: v2 name: ride version: 0.2.0 EOF cat > "$WS/q69/chart/values.yaml" <<'EOF' replicaCount: 1 image: nginx:1.27 EOF cat > "$WS/q69/chart/templates/deploy.yaml" <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: {name: {{ .Release.Name }}-server, namespace: {{ .Release.Namespace }}} spec: replicas: {{ .Values.replicaCount }} selector: {matchLabels: {app: {{ .Release.Name }}-server}} template: metadata: {labels: {app: {{ .Release.Name }}-server}} spec: {containers: [{name: server, image: "{{ .Values.image }}"}]} EOF $K create ns ride-gitops --dry-run=client -o yaml | $K apply -f - >/dev/null helm -n ride-gitops uninstall ride-argocd >/dev/null 2>&1 || true command -v helm >/dev/null || echo "warn: helm is not installed on this machine" """, checks=[ C("manifest.yaml holds rendered manifests, not an installed release", 3, r""" f="$WS/q69/manifest.yaml"; [ -s "$f" ] || exit 1 grep -q 'kind: Deployment' "$f" && grep -q 'ride-argocd-server' "$f" && grep -Eq 'replicas: 3' "$f" """), C("release ride-argocd is deployed in ride-gitops with 3 replicas", 3, r""" helm -n ride-gitops status ride-argocd -o json 2>/dev/null \ | python3 -c 'import sys,json;sys.exit(0 if json.load(sys.stdin)["info"]["status"]=="deployed" else 1)' || exit 1 for i in $(seq 1 20); do [ "$(j -n ride-gitops deploy ride-argocd-server '{.status.availableReplicas}')" = 3 ] && exit 0; sleep 5; done; exit 1"""), C("releases.txt lists the ride-argocd release", 3, r""" grep -q 'ride-argocd' "$WS/q69/releases.txt" 2>/dev/null && grep -qi 'deployed' "$WS/q69/releases.txt" """), ], solution=""" helm template ride-argocd {WS}/q69/chart -n ride-gitops --set replicaCount=3 > {WS}/q69/manifest.yaml k create ns ride-gitops helm -n ride-gitops install ride-argocd {WS}/q69/chart --set replicaCount=3 helm -n ride-gitops list > {WS}/q69/releases.txt # `helm template` renders locally and talks to no cluster - it is how you review what a chart # would do, and how GitOps pipelines produce committed YAML. `helm install` is the one that # creates a release. --create-namespace on install saves the separate `k create ns`. """, ) # ------------------------------------------------------------------ 70 ---- task( id=70, domain="TSHOOT", title="Fix the driver onboarding app", task=""" Deployment `onboard-app` in Namespace `driver-onboard` never becomes Ready. It has **three** separate faults. Fix the existing Deployment in place — do not delete and recreate it, and do not change the container name. All replicas must end up Running and Ready. """, setup=NS + r""" mkns driver-onboard $K -n driver-onboard delete deploy onboard-app --ignore-not-found --wait=true >/dev/null 2>&1 $K apply -f - >/dev/null <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: {name: onboard-app, namespace: driver-onboard} spec: replicas: 2 selector: {matchLabels: {app: onboard-app}} template: metadata: {labels: {app: onboard-app}} spec: containers: - name: onboard image: ngnix:1.25 ports: [{containerPort: 80}] livenessProbe: httpGet: {path: /, port: 9090} initialDelaySeconds: 5 readinessProbe: httpGet: {path: /healthz, port: 80} initialDelaySeconds: 3 EOF """, checks=[ C("container image is corrected to nginx (the ngnix typo is gone)", 3, r""" i=$(j -n driver-onboard deploy onboard-app '{.spec.template.spec.containers[0].image}') echo "$i" | grep -q '^nginx:' && ! echo "$i" | grep -q ngnix"""), C("liveness probe targets port 80", 2, r""" eq "$(j -n driver-onboard deploy onboard-app '{.spec.template.spec.containers[0].livenessProbe.httpGet.port}')" 80"""), # any readiness probe nginx can satisfy counts (/, /index.html, a tcpSocket on 80...); the # "all replicas Ready" check below proves it actually passes C("readiness probe no longer targets the missing /healthz", 2, r""" rp=$(j -n driver-onboard deploy onboard-app '{.spec.template.spec.containers[0].readinessProbe}') [ -n "$rp" ] && ! echo "$rp" | grep -q '/healthz'"""), C("all replicas Running and Ready", 2, r""" for i in $(seq 1 24); do [ "$(j -n driver-onboard deploy onboard-app '{.status.readyReplicas}')" = 2 ] && exit 0; sleep 5; done; exit 1"""), ], solution=""" k -n driver-onboard get pods # ImagePullBackOff k -n driver-onboard describe pod -l app=onboard-app | tail -20 k -n driver-onboard set image deploy/onboard-app onboard=nginx:1.25 # fault 1: image typo k -n driver-onboard edit deploy onboard-app # fault 2: livenessProbe.httpGet.port 9090 -> 80 (nothing listens on 9090, so the kubelet # kills the container in a loop once it finally starts) # fault 3: readinessProbe.httpGet.path /healthz -> / (nginx returns 404 there, so the pod # never joins the Service endpoints even when healthy) k -n driver-onboard rollout status deploy onboard-app # Fix them in one edit if you can: each change triggers a fresh rollout, and three sequential # rollouts on a 2-replica Deployment is a lot of wasted clock in an exam. """, ) # ------------------------------------------------------------------ 71 ---- task( id=71, domain="NET", title="Expose and verify the dispatch API", task=""" Deployment `dispatch-api` is running in Namespace `dispatch-hub` and serves on container port 80. 1. Expose it as a **ClusterIP** Service named `dispatch-service`, port 80 to target port 80. It must actually have endpoints. 2. From inside the cluster, fetch `http://dispatch-service.dispatch-hub.svc:80` and save the response body to `{WS}/q71/test-result.txt`. 3. Write the Service's ClusterIP — the address only, nothing else — to `{WS}/q71/service-ip.txt`. """, setup=NS + r""" mkns dispatch-hub rm -rf "$WS/q71"; mkdir -p "$WS/q71" $K -n dispatch-hub delete svc dispatch-service --ignore-not-found >/dev/null 2>&1 $K -n dispatch-hub delete deploy dispatch-api --ignore-not-found --wait=true >/dev/null 2>&1 $K -n dispatch-hub create deploy dispatch-api --image=nginx:1.27 --replicas=2 --port=80 >/dev/null $K -n dispatch-hub rollout status deploy dispatch-api --timeout=120s >/dev/null 2>&1 """, checks=[ C("dispatch-service is a ClusterIP Service on port 80 -> 80", 2, r""" s="$K -n dispatch-hub get svc dispatch-service" eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" ClusterIP \ && eq "$($s -o jsonpath='{.spec.ports[0].port}')" 80 \ && eq "$($s -o jsonpath='{.spec.ports[0].targetPort}')" 80"""), C("the Service has active endpoints", 3, r""" [ -n "$(j -n dispatch-hub endpointslices -l kubernetes.io/service-name=dispatch-service '{.items[*].endpoints[*].addresses[*]}')" ]"""), C("test-result.txt holds the response from nginx", 2, r""" grep -qi 'nginx\|/dev/null"""), C("service-ip.txt contains exactly the Service ClusterIP", 2, r""" want=$(j -n dispatch-hub svc dispatch-service '{.spec.clusterIP}') got=$(tr -d '[:space:]' < "$WS/q71/service-ip.txt" 2>/dev/null) [ -n "$want" ] && eq "$got" "$want" """), ], solution=""" k -n dispatch-hub expose deploy dispatch-api --name=dispatch-service --port=80 --target-port=80 k -n dispatch-hub get endpointslices -l kubernetes.io/service-name=dispatch-service # must not be empty k -n dispatch-hub run probe --image=busybox:1.36 --restart=Never -i --rm -- \\ wget -qO- http://dispatch-service.dispatch-hub.svc:80 > {WS}/q71/test-result.txt k -n dispatch-hub get svc dispatch-service -o jsonpath='{.spec.clusterIP}' > {WS}/q71/service-ip.txt # `expose` copies the Deployment's selector for you, which is what makes the endpoints appear. # Write the IP with jsonpath, not by eyeballing `get svc` - the grader compares it literally. """, ) # ------------------------------------------------------------------ 72 ---- task( id=72, domain="STOR", title="Migrate ride history to a Retain volume", task=""" In Namespace `ride-history`, Deployment `ride-archive` stores data on PVC `ride-archive-pvc`, whose PersistentVolume has reclaim policy `Delete` — losing the claim would destroy the data. Migrate it: 1. Create PersistentVolume `ride-archive-pv-new`: 500Mi, ReadWriteOnce, hostPath `/data/ride-archive-new`, storageClassName `manual`, reclaim policy **Retain**. 2. Create PVC `ride-archive-pvc-new` in `ride-history` bound to that PV **by name**, 500Mi, ReadWriteOnce, storageClassName `manual`. 3. Copy the existing contents of the old volume to the new one. The file `trips.csv` must end up on the new volume. 4. Point Deployment `ride-archive` at `ride-archive-pvc-new` and get its pods Running again. The old claim is ReadWriteOnce, so something has to release it before a migration pod can mount it. """, setup=NS + r""" mkns ride-history $K -n ride-history delete deploy ride-archive --ignore-not-found --wait=true >/dev/null 2>&1 $K -n ride-history delete pod data-migrator --ignore-not-found --wait=false >/dev/null 2>&1 $K -n ride-history delete pvc ride-archive-pvc ride-archive-pvc-new --ignore-not-found --wait=true >/dev/null 2>&1 $K delete pv ride-archive-pv ride-archive-pv-new --ignore-not-found --wait=true >/dev/null 2>&1 # seed the source data on every node so the pod can land anywhere for n in "$CP" "$W1" "$W2" "$W3"; do nx "$n" 'mkdir -p /data/ride-archive && printf "trip,driver,fare\n1,alice,12.50\n2,bob,8.25\n" > /data/ride-archive/trips.csv; rm -rf /data/ride-archive-new' /dev/null <<'EOF' apiVersion: v1 kind: PersistentVolume metadata: {name: ride-archive-pv} spec: capacity: {storage: 500Mi} accessModes: [ReadWriteOnce] hostPath: {path: /data/ride-archive} storageClassName: manual persistentVolumeReclaimPolicy: Delete --- apiVersion: v1 kind: PersistentVolumeClaim metadata: {name: ride-archive-pvc, namespace: ride-history} spec: accessModes: [ReadWriteOnce] resources: {requests: {storage: 500Mi}} storageClassName: manual volumeName: ride-archive-pv --- apiVersion: apps/v1 kind: Deployment metadata: {name: ride-archive, namespace: ride-history} spec: replicas: 1 selector: {matchLabels: {app: ride-archive}} template: metadata: {labels: {app: ride-archive}} spec: volumes: [{name: data, persistentVolumeClaim: {claimName: ride-archive-pvc}}] containers: - name: archive image: busybox:1.36 command: [sh, -c, "sleep 86400"] volumeMounts: [{name: data, mountPath: /archive}] EOF $K -n ride-history rollout status deploy ride-archive --timeout=120s >/dev/null 2>&1 """, checks=[ C("PV ride-archive-pv-new exists with Retain and the right spec", 3, r""" p="$K get pv ride-archive-pv-new" eq "$($p -o jsonpath='{.spec.capacity.storage}' 2>/dev/null)" 500Mi \ && eq "$($p -o jsonpath='{.spec.hostPath.path}')" /data/ride-archive-new \ && eq "$($p -o jsonpath='{.spec.persistentVolumeReclaimPolicy}')" Retain \ && eq "$($p -o jsonpath='{.spec.storageClassName}')" manual"""), C("PVC ride-archive-pvc-new is Bound to it by volumeName", 2, r""" eq "$(j -n ride-history pvc ride-archive-pvc-new '{.status.phase}')" Bound \ && eq "$(j -n ride-history pvc ride-archive-pvc-new '{.spec.volumeName}')" ride-archive-pv-new"""), C("trips.csv was copied onto the new volume", 3, r""" for n in "$CP" "$W1" "$W2" "$W3"; do nx "$n" 'grep -q alice /data/ride-archive-new/trips.csv 2>/dev/null' /dev/null && exit 0; sleep 5; done; exit 1"""), ], solution=""" k -n ride-history get pvc,pv # note reclaimPolicy Delete on the old PV cat </dev/null 2>&1 $K -n eta-service delete deploy eta-calculator --ignore-not-found --wait=true >/dev/null 2>&1 $K -n eta-service create deploy eta-calculator --image=nginx:1.27 --replicas=2 --port=80 >/dev/null $K apply -f - >/dev/null <<'EOF' apiVersion: v1 kind: Service metadata: {name: eta-backend, namespace: eta-service} spec: selector: {app: eta-service} ports: [{port: 80, targetPort: 9090, protocol: TCP}] EOF $K -n eta-service rollout status deploy eta-calculator --timeout=120s >/dev/null 2>&1 """, checks=[ C("Service selector matches the deployment's pod labels", 3, r""" want=$(j -n eta-service deploy eta-calculator '{.spec.template.metadata.labels.app}') [ -n "$want" ] && eq "$(j -n eta-service svc eta-backend '{.spec.selector.app}')" "$want" """), C("Service targetPort matches the container port 80", 2, r""" eq "$(j -n eta-service svc eta-backend '{.spec.ports[0].targetPort}')" 80"""), C("the Service has active endpoints", 2, r""" for i in $(seq 1 12); do [ -n "$(j -n eta-service endpointslices -l kubernetes.io/service-name=eta-backend '{.items[*].endpoints[*].addresses[*]}')" ] && exit 0 sleep 3 done; exit 1"""), ], solution=""" k -n eta-service get endpointslices -l kubernetes.io/service-name=eta-backend # empty k -n eta-service get deploy eta-calculator -o jsonpath='{.spec.template.metadata.labels}' # app=eta-calculator k -n eta-service get svc eta-backend -o jsonpath='{.spec.selector}' # app=eta-service <- fault 1 k -n eta-service patch svc eta-backend -p '{"spec":{"selector":{"app":"eta-calculator"}}}' k -n eta-service patch svc eta-backend -p '{"spec":{"ports":[{"port":80,"targetPort":80,"protocol":"TCP"}]}}' k -n eta-service get endpointslices -l kubernetes.io/service-name=eta-backend # Empty endpoints is almost always the selector; a populated endpoint list that still refuses # connections is almost always targetPort. Check the endpoints first - it splits the two cases # in one command. """, ) # ------------------------------------------------------------------ 74 ---- task( id=74, domain="NET", title="Fleet dashboard canary with Gateway API", task=""" Namespace `fleet-ops` has Services `fleet-dashboard-v1` and `fleet-dashboard-v2` (both port 80) and a GatewayClass `fleet-gateway-class` already exists. 1. Create Gateway `fleet-gateway` in `fleet-ops` using that GatewayClass, with a single HTTP listener named `http` on port 80 for hostname `fleet.example.com`. 2. Create HTTPRoute `fleet-route` in `fleet-ops`, attached to `fleet-gateway`, that: - sends requests carrying header `X-Fleet-Version: canary` to `fleet-dashboard-v2` port 80, - sends everything else (path prefix `/`) to `fleet-dashboard-v1` port 80. (No Gateway controller runs here, so you are graded on the objects.) """, setup=NS + r""" mkns fleet-ops $K -n fleet-ops delete httproute fleet-route --ignore-not-found >/dev/null 2>&1 $K -n fleet-ops delete gateway fleet-gateway --ignore-not-found >/dev/null 2>&1 for v in v1 v2; do $K -n fleet-ops create deploy fleet-dashboard-$v --image=nginx:1.27 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null $K -n fleet-ops expose deploy fleet-dashboard-$v --port=80 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null done ensure_gateway_crds || { echo "no Gateway API CRDs and could not download them (no network?) - install them, then re-seed"; exit 1; } $K apply -f - >/dev/null <<'EOF' apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: {name: fleet-gateway-class} spec: {controllerName: example.com/fleet-controller} EOF """, checks=[ C("Gateway fleet-gateway uses GatewayClass fleet-gateway-class", 2, r""" eq "$(j -n fleet-ops gateway fleet-gateway '{.spec.gatewayClassName}')" fleet-gateway-class"""), C("listener 'http' on port 80 for fleet.example.com", 2, r""" g="$K -n fleet-ops get gateway fleet-gateway" eq "$($g -o jsonpath='{.spec.listeners[0].name}' 2>/dev/null)" http \ && eq "$($g -o jsonpath='{.spec.listeners[0].port}')" 80 \ && eq "$($g -o jsonpath='{.spec.listeners[0].protocol}')" HTTP \ && eq "$($g -o jsonpath='{.spec.listeners[0].hostname}')" fleet.example.com"""), C("a header rule X-Fleet-Version: canary routes to fleet-dashboard-v2", 3, r""" $K -n fleet-ops get httproute fleet-route -o json 2>/dev/null | python3 -c ' import sys,json rules=json.load(sys.stdin)["spec"].get("rules",[]) ok=any( any(h.get("name","").lower()=="x-fleet-version" and h.get("value")=="canary" for m in r.get("matches",[]) for h in m.get("headers",[])) and any(b.get("name")=="fleet-dashboard-v2" and int(b.get("port",0))==80 for b in r.get("backendRefs",[])) for r in rules) sys.exit(0 if ok else 1)'"""), C("a default path-prefix rule routes to fleet-dashboard-v1", 2, r""" $K -n fleet-ops get httproute fleet-route -o json 2>/dev/null | python3 -c ' import sys,json rules=json.load(sys.stdin)["spec"].get("rules",[]) ok=any( any(m.get("path",{}).get("type")=="PathPrefix" and m.get("path",{}).get("value")=="/" and not m.get("headers") for m in r.get("matches",[])) and any(b.get("name")=="fleet-dashboard-v1" and int(b.get("port",0))==80 for b in r.get("backendRefs",[])) for r in rules) sys.exit(0 if ok else 1)'"""), C("the HTTPRoute is attached to fleet-gateway", 1, r""" j -n fleet-ops httproute fleet-route '{.spec.parentRefs[*].name}' | grep -qw fleet-gateway"""), ], solution=""" cat </dev/null 2>&1 $K delete clusterrole auth-cluster-viewer --ignore-not-found >/dev/null 2>&1 $K -n rider-auth delete sa auth-auditor --ignore-not-found >/dev/null 2>&1 """, checks=[ C("ServiceAccount rider-auth/auth-auditor exists", 2, r"""$K -n rider-auth get sa auth-auditor >/dev/null 2>&1"""), C("ClusterRole auth-cluster-viewer grants exactly read verbs on the four resources", 3, r""" $K get clusterrole auth-cluster-viewer -o json 2>/dev/null | python3 -c ' import sys,json rules=json.load(sys.stdin).get("rules",[]) verbs=set(); res=set() for r in rules: verbs|=set(r.get("verbs",[])); res|=set(r.get("resources",[])) sys.exit(0 if verbs=={"get","list","watch"} and {"pods","deployments","services","events"}<=res else 1)'"""), C("the binding grants it cluster-wide", 2, r""" b="$K get clusterrolebinding auth-viewer-binding" eq "$($b -o jsonpath='{.roleRef.name}' 2>/dev/null)" auth-cluster-viewer \ && $b -o jsonpath='{.subjects[*].name}' | grep -qw auth-auditor \ && eq "$($K auth can-i list pods -n kube-system --as=system:serviceaccount:rider-auth:auth-auditor 2>/dev/null)" yes"""), C("auth-check.txt records the verification result", 2, r""" grep -qx 'yes' "$WS/q75/auth-check.txt" 2>/dev/null"""), ], solution=""" k -n rider-auth create sa auth-auditor k create clusterrole auth-cluster-viewer --verb=get,list,watch --resource=pods,deployments,services,events k create clusterrolebinding auth-viewer-binding --clusterrole=auth-cluster-viewer \\ --serviceaccount=rider-auth:auth-auditor k auth can-i list pods -n kube-system --as=system:serviceaccount:rider-auth:auth-auditor > {WS}/q75/auth-check.txt # A ClusterRole bound with a RoleBinding only applies in that one Namespace - it is the # ClusterRoleBinding that makes it cluster-wide. The --serviceaccount flag takes ns:name. """, ) # ------------------------------------------------------------------ 76 ---- task( id=76, domain="WORK", title="Add an init container to the trip logger", task=""" Deployment `trip-logger` in Namespace `trip-tracker` must be given a startup step. Add to its Pod template: - an **init container** named `config-init` using `busybox:1.36` that writes the JSON `{"region":"us-east","mode":"production"}` to `/config/app-config.json`, - an `emptyDir` volume named `config-volume` mounted at `/config` in **both** the init container and the main container. Pods must end up Running with the init container Completed, and the file must be readable from the main container. """, setup=NS + r""" mkns trip-tracker $K -n trip-tracker delete deploy trip-logger --ignore-not-found --wait=true >/dev/null 2>&1 $K apply -f - >/dev/null <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: {name: trip-logger, namespace: trip-tracker} spec: replicas: 1 selector: {matchLabels: {app: trip-logger}} template: metadata: {labels: {app: trip-logger}} spec: containers: - name: logger image: nginx:1.27 EOF $K -n trip-tracker rollout status deploy trip-logger --timeout=120s >/dev/null 2>&1 """, checks=[ C("init container config-init exists on the deployment", 2, r""" eq "$(j -n trip-tracker deploy trip-logger '{.spec.template.spec.initContainers[?(@.name=="config-init")].name}')" config-init"""), C("it uses busybox:1.36 and targets /config/app-config.json", 3, r""" c='{.spec.template.spec.initContainers[?(@.name=="config-init")]' eq "$(j -n trip-tracker deploy trip-logger "$c.image}")" busybox:1.36 \ && { j -n trip-tracker deploy trip-logger "$c.command[*]}"; j -n trip-tracker deploy trip-logger "$c.args[*]}"; } | grep -q '/config/app-config.json'"""), C("emptyDir config-volume mounted at /config in both containers", 2, r""" d="$K -n trip-tracker get deploy trip-logger" [ -n "$($d -o jsonpath='{.spec.template.spec.volumes[?(@.name=="config-volume")].emptyDir}')" ] \ && eq "$($d -o jsonpath='{.spec.template.spec.initContainers[?(@.name=="config-init")].volumeMounts[?(@.name=="config-volume")].mountPath}')" /config \ && eq "$($d -o jsonpath='{.spec.template.spec.containers[0].volumeMounts[?(@.name=="config-volume")].mountPath}')" /config"""), C("pods Running and the config file is present in the main container", 2, r""" for i in $(seq 1 20); do [ "$(j -n trip-tracker deploy trip-logger '{.status.readyReplicas}')" = 1 ] && break; sleep 5; done p=$(j -n trip-tracker pod -l app=trip-logger '{.items[0].metadata.name}'); [ -n "$p" ] || exit 1 $K -n trip-tracker exec "$p" -- cat /config/app-config.json 2>/dev/null | grep -q 'us-east'"""), ], solution=""" k -n trip-tracker edit deploy trip-logger # add to spec.template.spec: # volumes: # - {name: config-volume, emptyDir: {}} # initContainers: # - name: config-init # image: busybox:1.36 # command: ["sh","-c","echo '{\\"region\\":\\"us-east\\",\\"mode\\":\\"production\\"}' > /config/app-config.json"] # volumeMounts: [{name: config-volume, mountPath: /config}] # and the SAME volumeMount on the main container k -n trip-tracker rollout status deploy trip-logger # An init container runs to completion before the main containers start, and an emptyDir is the # handover: it lives as long as the Pod, so whatever init writes is there when the app boots. # Quoting that JSON inside a YAML command list is the fiddly part - single-quote the shell # string and escape the inner double quotes. """, ) # ------------------------------------------------------------------ 77 ---- task( id=77, domain="NET", title="Audit the CNI, then lock a namespace down", task=""" Before rolling out NetworkPolicies, confirm what the cluster's CNI can actually enforce. 1. Write the name of the DaemonSet in `kube-system` that provides pod networking to `{WS}/q77/cni.txt`. 2. Write `yes` or `no` to `{WS}/q77/enforced.txt`: does this CNI enforce NetworkPolicy? 3. In Namespace `zero-trust`, create NetworkPolicy `default-deny-all` that denies **all** ingress and egress for every Pod in the Namespace, with no allow rules of any kind. Answer step 2 from evidence, not memory — several popular CNIs accept NetworkPolicy objects happily and never enforce them, and a CNI's behaviour can change between releases. """, setup=NS + r""" mkns zero-trust rm -rf "$WS/q77"; mkdir -p "$WS/q77" $K -n zero-trust delete netpol default-deny-all --ignore-not-found >/dev/null 2>&1 """, checks=[ C("cni.txt names the CNI DaemonSet in kube-system", 2, r""" want=$($K -n kube-system get ds -o name 2>/dev/null | sed 's|.*/||' | grep -Ei 'kindnet|calico|cilium|flannel|weave' | head -1) [ -n "$want" ] || want=kindnet grep -qi "$want" "$WS/q77/cni.txt" 2>/dev/null"""), # The right answer depends on the build, so derive it: recent kindnet embeds a policy engine # (nftables table `kindnet-network-policies` on every node - verified enforcing on this # cluster), calico/cilium/weave enforce, flannel does not. C("enforced.txt gives the right answer for this cluster's CNI", 2, r""" ds=$($K -n kube-system get ds -o name 2>/dev/null | sed 's|.*/||' | tr '\n' ' ') case "$ds" in *calico*|*cilium*|*weave*|*antrea*) want=yes ;; *kindnet*) nx "$W1" 'nft list table inet kindnet-network-policies >/dev/null 2>&1' /dev/null"""), C("default-deny-all selects every pod in both directions", 4, r""" n="$K -n zero-trust get netpol default-deny-all" eq "$($n -o jsonpath='{.spec.podSelector}' 2>/dev/null)" '{}' \ && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress \ && $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress \ && [ -z "$($n -o jsonpath='{.spec.ingress}')" ] && [ -z "$($n -o jsonpath='{.spec.egress}')" ]"""), ], solution=""" k -n kube-system get ds # kindnet -> {WS}/q77/cni.txt # Don't answer from memory - prove it. Two pods, one deny policy, one wget: k -n zero-trust run srv --image=nginx:1.27 --labels=app=srv; k -n zero-trust expose pod srv --port 80 k -n zero-trust run cli --image=busybox:1.36 -- sleep 3600 k -n zero-trust exec cli -- wget -qO- -T3 http://srv >/dev/null && echo reachable cat </dev/null || echo BLOCKED # -> BLOCKED here echo yes > {WS}/q77/enforced.txt k -n zero-trust delete pod srv cli; k -n zero-trust delete svc srv # leave only the policy # podSelector {} means every pod in the namespace; naming both policyTypes with no ingress/egress # blocks listed means "deny everything". Recent kindnet builds (this cluster's included) embed a # policy engine - look for `nft list table inet kindnet-network-policies` on a node. Flannel stores # NetworkPolicies and enforces nothing; Calico and Cilium enforce. A policy that "works" on a # non-enforcing CNI is the classic hardening-review trap. """, ) # ------------------------------------------------------------------ 78 ---- task( id=78, domain="WORK", title="Priority for the rider support portal", task=""" 1. Create PriorityClass `rider-support-priority` with value `1000000`, **not** the global default, and description `Priority for rider support services`. 2. Make Deployment `support-portal` in Namespace `rider-support` use it, and get the pods Running. """, setup=NS + r""" mkns rider-support $K delete priorityclass rider-support-priority --ignore-not-found >/dev/null 2>&1 $K -n rider-support delete deploy support-portal --ignore-not-found --wait=true >/dev/null 2>&1 $K -n rider-support create deploy support-portal --image=nginx:1.27 --replicas=2 >/dev/null $K -n rider-support rollout status deploy support-portal --timeout=120s >/dev/null 2>&1 """, checks=[ C("PriorityClass has value 1000000 and is not the global default", 3, r""" p="$K get priorityclass rider-support-priority" eq "$($p -o jsonpath='{.value}' 2>/dev/null)" 1000000 \ && [ "$($p -o jsonpath='{.globalDefault}')" != true ] \ && $p -o jsonpath='{.description}' | grep -q 'rider support'"""), C("the deployment's pod template requests that PriorityClass", 2, r""" eq "$(j -n rider-support deploy support-portal '{.spec.template.spec.priorityClassName}')" rider-support-priority"""), C("pods are Running and actually carry the priority value", 2, r""" for i in $(seq 1 20); do [ "$(j -n rider-support deploy support-portal '{.status.readyReplicas}')" = 2 ] && break; sleep 5; done eq "$(j -n rider-support deploy support-portal '{.status.readyReplicas}')" 2 \ && eq "$(j -n rider-support pod -l app=support-portal '{.items[0].spec.priority}')" 1000000"""), ], solution=""" cat </dev/null 2>&1 $K -n surge-pricing delete secret surge-db-secret --ignore-not-found >/dev/null 2>&1 $K -n surge-pricing delete deploy surge-engine --ignore-not-found --wait=true >/dev/null 2>&1 $K -n surge-pricing create deploy surge-engine --image=nginx:1.27 >/dev/null $K -n surge-pricing rollout status deploy surge-engine --timeout=120s >/dev/null 2>&1 """, checks=[ C("ConfigMap surge-config holds all three keys", 2, r""" c="$K -n surge-pricing get cm surge-config" eq "$($c -o jsonpath='{.data.PRICING_MODE}' 2>/dev/null)" dynamic \ && eq "$($c -o jsonpath='{.data.MAX_MULTIPLIER}')" 3.5 \ && eq "$($c -o jsonpath='{.data.REGION}')" us-east-1"""), C("Secret surge-db-secret holds all three keys", 2, r""" s="$K -n surge-pricing get secret surge-db-secret" for k in DB_HOST DB_USER DB_PASSWORD; do [ -n "$($s -o jsonpath="{.data.$k}" 2>/dev/null)" ] || exit 1; done"""), C("the deployment uses envFrom for both the ConfigMap and the Secret", 3, r""" e=$(j -n surge-pricing deploy surge-engine '{.spec.template.spec.containers[0].envFrom}') echo "$e" | grep -q 'surge-config' && echo "$e" | grep -q 'surge-db-secret' \ && echo "$e" | grep -q configMapRef && echo "$e" | grep -q secretRef"""), C("the ConfigMap is also mounted as a volume at /etc/surge-config", 2, r""" d="$K -n surge-pricing get deploy surge-engine" $d -o jsonpath='{.spec.template.spec.volumes[*].configMap.name}' | grep -qw surge-config \ && $d -o jsonpath='{.spec.template.spec.containers[0].volumeMounts[*].mountPath}' | grep -q '/etc/surge-config'"""), C("pods Running with the variables visible in the container", 2, r""" for i in $(seq 1 20); do [ "$(j -n surge-pricing deploy surge-engine '{.status.readyReplicas}')" -ge 1 ] 2>/dev/null && break; sleep 5; done p=$(j -n surge-pricing pod -l app=surge-engine '{.items[0].metadata.name}'); [ -n "$p" ] || exit 1 $K -n surge-pricing exec "$p" -- env 2>/dev/null | grep -q 'PRICING_MODE=dynamic' \ && $K -n surge-pricing exec "$p" -- env 2>/dev/null | grep -q 'DB_HOST='"""), ], solution=""" k -n surge-pricing create cm surge-config \\ --from-literal=PRICING_MODE=dynamic --from-literal=MAX_MULTIPLIER=3.5 --from-literal=REGION=us-east-1 k -n surge-pricing create secret generic surge-db-secret \\ --from-literal=DB_HOST=pricing-db.surge-pricing.svc.cluster.local \\ --from-literal=DB_USER=surge_admin --from-literal=DB_PASSWORD='S3cur3Pr1c1ng!' k -n surge-pricing edit deploy surge-engine # in the container: # envFrom: # - configMapRef: {name: surge-config} # - secretRef: {name: surge-db-secret} # volumeMounts: [{name: config-vol, mountPath: /etc/surge-config}] # and at pod level: volumes: [{name: config-vol, configMap: {name: surge-config}}] k -n surge-pricing rollout status deploy surge-engine # envFrom pulls EVERY key in one line; `env` with valueFrom is for cherry-picking single keys or # renaming them. As a volume each key becomes a file named after the key - the same ConfigMap can # legitimately be consumed both ways at once, which is exactly what this task asks for. """, ) # ------------------------------------------------------------------ 80 ---- task( id=80, domain="ARCH", title="Plan the control-plane upgrade", task=""" The control-plane node is scheduled for a minor-version upgrade. Prepare the change — **do not execute an upgrade**. 1. Write the cluster's current server version, as `kubectl` reports it, to `{WS}/q80/current.txt`. 2. Write every node's name and kubelet version, one node per line, to `{WS}/q80/kubelets.txt`. 3. Write to `{WS}/q80/steps.sh` the ordered commands to upgrade the **control-plane** node — the kubeadm package install, the kubeadm upgrade step, draining, the kubelet/kubectl package install, restarting the kubelet, and uncordoning — one command per line, in execution order. 4. Write to `{WS}/q80/worker.txt` the single kubeadm subcommand that upgrades a **worker** node instead (it is not `upgrade apply`). """, setup=r"""rm -rf "$WS/q80"; mkdir -p "$WS/q80" """, checks=[ C("current.txt matches the live server version", 2, r""" v=$($K version -o json 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])') [ -n "$v" ] && grep -qF "$v" "$WS/q80/current.txt" 2>/dev/null"""), C("kubelets.txt lists every node with its kubelet version", 2, r""" f="$WS/q80/kubelets.txt"; [ -s "$f" ] || exit 1 for n in $($K get nodes -o jsonpath='{.items[*].metadata.name}'); do grep -q "$n" "$f" || exit 1; done v=$($K get nodes -o jsonpath='{.items[0].status.nodeInfo.kubeletVersion}') grep -qF "$v" "$f" """), C("steps.sh has the upgrade commands in a workable order", 4, r""" f="$WS/q80/steps.sh"; [ -f "$f" ] || exit 1 grep -q 'kubeadm upgrade apply' "$f" || exit 1 grep -Eq '(apt|yum|dnf).*install.*kubeadm' "$f" || exit 1 grep -Eq '(apt|yum|dnf).*install.*kubelet' "$f" || exit 1 grep -q 'drain' "$f" && grep -q 'uncordon' "$f" || exit 1 grep -Eq 'systemctl (restart|daemon-reload)' "$f" || exit 1 ap=$(grep -n 'kubeadm upgrade apply' "$f" | head -1 | cut -d: -f1) ka=$(grep -nE '(apt|yum|dnf).*install.*kubeadm' "$f" | head -1 | cut -d: -f1) kl=$(grep -nE '(apt|yum|dnf).*install.*kubelet' "$f" | head -1 | cut -d: -f1) un=$(grep -n 'uncordon' "$f" | head -1 | cut -d: -f1) [ "$ka" -lt "$ap" ] && [ "$ap" -lt "$kl" ] && [ "$kl" -lt "$un" ]"""), C("worker.txt names `kubeadm upgrade node`", 2, r""" grep -Eq 'kubeadm[[:space:]]+upgrade[[:space:]]+node' "$WS/q80/worker.txt" 2>/dev/null \ && ! grep -q 'upgrade apply' "$WS/q80/worker.txt" """), ], solution=""" k version -o json | jq -r .serverVersion.gitVersion > {WS}/q80/current.txt k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion --no-headers > {WS}/q80/kubelets.txt cat > {WS}/q80/steps.sh <<'EOF' sed -i 's|/v1.37/|/v1.38/|' /etc/apt/sources.list.d/kubernetes.list apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.38.0-1.1 && apt-mark hold kubeadm kubeadm upgrade plan kubeadm upgrade apply v1.38.0 -y kubectl drain --ignore-daemonsets apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.38.0-1.1 kubectl=1.38.0-1.1 && apt-mark hold kubelet kubectl systemctl daemon-reload systemctl restart kubelet kubectl uncordon EOF echo 'kubeadm upgrade node' > {WS}/q80/worker.txt # The cluster runs v1.37, so the next minor is 1.38 - and pkgs.k8s.io serves one apt repo per # minor, so the repo line has to move to v1.38 before 1.38 packages exist for apt at all. (1.38 # is due in December 2026; until then that repo is empty, which is why this is only a plan. Take # the newest 1.38 patch on offer then: `apt-cache madison kubeadm`.) # kubeadm goes first because it drives the whole thing; the control plane is upgraded by # `upgrade apply`, and only then do the node binaries (kubelet/kubectl) follow, ending with # uncordon. Workers run `kubeadm upgrade node` - they host no control-plane static pods, so # there is nothing for `upgrade apply` to do there. The kubelet may trail the API server by up # to three minor versions, but must never be ahead of it. """, ) # ------------------------------------------------------------------ 81 ---- task( id=81, domain="TSHOOT", title="Fix the fare calculation pods", task=""" Deployment `fare-calculator` in Namespace `fare-engine` has no running pods. There are **two** independent reasons, one storage and one scheduling. Diagnose both and get the pods Running and Ready. You may change the Deployment and the claim, but keep the Deployment's name. """, setup=NS + r""" mkns fare-engine $K -n fare-engine delete deploy fare-calculator --ignore-not-found --wait=true >/dev/null 2>&1 $K -n fare-engine delete pvc fare-data-pvc --ignore-not-found --wait=true >/dev/null 2>&1 for n in "$W1" "$W2" "$W3"; do $K label node "$n" disk- >/dev/null 2>&1 || true; done $K apply -f - >/dev/null <<'EOF' apiVersion: v1 kind: PersistentVolumeClaim metadata: {name: fare-data-pvc, namespace: fare-engine} spec: accessModes: [ReadWriteOnce] resources: {requests: {storage: 100Mi}} storageClassName: fast-storage --- apiVersion: apps/v1 kind: Deployment metadata: {name: fare-calculator, namespace: fare-engine} spec: replicas: 1 selector: {matchLabels: {app: fare-calculator}} template: metadata: {labels: {app: fare-calculator}} spec: affinity: nodeAffinity: requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: [{key: disk, operator: In, values: [nvme]}] volumes: [{name: data, persistentVolumeClaim: {claimName: fare-data-pvc}}] containers: - name: fare image: nginx:1.27 volumeMounts: [{name: data, mountPath: /data}] EOF """, checks=[ C("fare-data-pvc is Bound using a StorageClass that exists", 3, r""" eq "$(j -n fare-engine pvc fare-data-pvc '{.status.phase}')" Bound || exit 1 sc=$(j -n fare-engine pvc fare-data-pvc '{.spec.storageClassName}') [ -z "$sc" ] || $K get sc "$sc" >/dev/null 2>&1"""), C("the pods can actually be scheduled", 3, r""" for i in $(seq 1 24); do n=$(j -n fare-engine pod -l app=fare-calculator '{.items[0].spec.nodeName}') [ -n "$n" ] && exit 0; sleep 5 done; exit 1"""), C("fare-calculator pods are Running and Ready", 3, r""" for i in $(seq 1 24); do [ "$(j -n fare-engine deploy fare-calculator '{.status.readyReplicas}')" -ge 1 ] 2>/dev/null && exit 0; sleep 5; done; exit 1"""), ], solution=""" k -n fare-engine get pvc # Pending k -n fare-engine describe pvc fare-data-pvc # storageclass.storage.k8s.io "fast-storage" not found k get sc # on kind the class is called `standard` # A PVC's storageClassName is immutable, so recreate it with the name your cluster really has: k -n fare-engine delete pvc fare-data-pvc cat < disk=nvme k -n fare-engine rollout status deploy fare-calculator # Two independent blockers stack up silently: the PVC never binds, AND nothing would schedule # even if it did. `describe pod` reports only the scheduling failure, so check the PVC too # whenever a pod that mounts one is stuck Pending. # Never assume the class name - `standard` here, `local-path` on a stock local-path-provisioner # install, something else on a cloud cluster. `k get sc` first, every time; a PVC naming a class # that does not exist sits Pending forever with no other symptom. # `standard` is WaitForFirstConsumer, so the PVC only binds once the pod can actually be placed - # which is why fixing the affinity is what finally unblocks the storage too. """, ) # ------------------------------------------------------------------ 82 ---- task( id=82, domain="WORK", title="Autoscale the route planner on memory", task=""" Create HorizontalPodAutoscaler `route-planner-hpa` in Namespace `route-planner` for Deployment `route-planner`: - minimum 1 replica, maximum 6, - scales on **memory** utilisation with a target average of 70%, - on the way up, may add at most **2 pods per 60 seconds**. Use the `autoscaling/v2` API. """, setup=NS + r""" mkns route-planner $K -n route-planner delete hpa route-planner-hpa --ignore-not-found >/dev/null 2>&1 $K -n route-planner delete deploy route-planner --ignore-not-found --wait=true >/dev/null 2>&1 $K -n route-planner create deploy route-planner --image=nginx:1.27 --replicas=1 >/dev/null $K -n route-planner set resources deploy route-planner --requests=cpu=50m,memory=64Mi >/dev/null $K -n route-planner rollout status deploy route-planner --timeout=120s >/dev/null 2>&1 """, checks=[ C("HPA targets the route-planner Deployment via autoscaling/v2", 2, r""" h="$K -n route-planner get hpa route-planner-hpa" eq "$($h -o jsonpath='{.spec.scaleTargetRef.kind}' 2>/dev/null)" Deployment \ && eq "$($h -o jsonpath='{.spec.scaleTargetRef.name}')" route-planner \ && $K -n route-planner get hpa.v2.autoscaling route-planner-hpa >/dev/null 2>&1"""), C("min 1, max 6 replicas", 2, r""" h="$K -n route-planner get hpa route-planner-hpa" eq "$($h -o jsonpath='{.spec.minReplicas}' 2>/dev/null)" 1 && eq "$($h -o jsonpath='{.spec.maxReplicas}')" 6"""), C("scales on memory utilisation at 70%", 3, r""" $K -n route-planner get hpa route-planner-hpa -o json 2>/dev/null | python3 -c ' import sys,json ms=json.load(sys.stdin)["spec"].get("metrics",[]) ok=any(m.get("type")=="Resource" and m["resource"]["name"]=="memory" and m["resource"]["target"].get("type")=="Utilization" and int(m["resource"]["target"].get("averageUtilization",0))==70 for m in ms) sys.exit(0 if ok else 1)'"""), C("scaleUp policy allows at most 2 pods per 60 seconds", 2, r""" $K -n route-planner get hpa route-planner-hpa -o json 2>/dev/null | python3 -c ' import sys,json b=json.load(sys.stdin)["spec"].get("behavior",{}).get("scaleUp",{}) ok=any(p.get("type")=="Pods" and int(p.get("value",0))==2 and int(p.get("periodSeconds",0))==60 for p in b.get("policies",[])) sys.exit(0 if ok else 1)'"""), ], solution=""" cat < and never scales. """, ) # ------------------------------------------------------------------ 83 ---- task( id=83, domain="NET", title="NetworkPolicy for the safety monitor", task=""" In Namespace `safety-monitor`, create NetworkPolicy `safety-api-policy` for the Pods labelled `app=safety-api`: - **ingress**: only from Pods labelled `app=safety-dashboard`, on TCP 8443, - **egress**: to Pods labelled `app=safety-db` on TCP 5432, - **egress**: DNS on port 53, both UDP and TCP, to any destination, - everything else denied in both directions. (This cluster's CNI enforces policies, so a wrong policy really breaks traffic — but you are graded on the object.) """, setup=NS + r""" mkns safety-monitor $K -n safety-monitor delete netpol safety-api-policy --ignore-not-found >/dev/null 2>&1 for a in safety-api safety-dashboard safety-db; do $K -n safety-monitor create deploy $a --image=nginx:1.27 --dry-run=client -o yaml | $K apply -f - >/dev/null $K -n safety-monitor patch deploy $a -p "{\"spec\":{\"template\":{\"metadata\":{\"labels\":{\"app\":\"$a\"}}}}}" >/dev/null done """, checks=[ C("selects app=safety-api and declares both policyTypes", 2, r""" n="$K -n safety-monitor get netpol safety-api-policy" eq "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" safety-api \ && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress && $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress"""), C("ingress only from app=safety-dashboard on TCP 8443", 3, r""" $K -n safety-monitor get netpol safety-api-policy -o json 2>/dev/null | python3 -c ' import sys,json ing=json.load(sys.stdin)["spec"].get("ingress",[]) ok=len(ing)==1 and len(ing[0].get("from",[]))==1 \ and ing[0]["from"][0].get("podSelector",{}).get("matchLabels",{}).get("app")=="safety-dashboard" \ and any(str(p.get("port"))=="8443" and p.get("protocol","TCP")=="TCP" for p in ing[0].get("ports",[])) sys.exit(0 if ok else 1)'"""), C("egress to app=safety-db on TCP 5432", 3, r""" $K -n safety-monitor get netpol safety-api-policy -o json 2>/dev/null | python3 -c ' import sys,json eg=json.load(sys.stdin)["spec"].get("egress",[]) ok=any(any(t.get("podSelector",{}).get("matchLabels",{}).get("app")=="safety-db" for t in r.get("to",[])) and any(str(p.get("port"))=="5432" for p in r.get("ports",[])) for r in eg) sys.exit(0 if ok else 1)'"""), C("egress DNS on 53 over both UDP and TCP, unrestricted destination", 2, r""" $K -n safety-monitor get netpol safety-api-policy -o json 2>/dev/null | python3 -c ' import sys,json eg=json.load(sys.stdin)["spec"].get("egress",[]) ok=any(not r.get("to") and {p.get("protocol") for p in r.get("ports",[]) if str(p.get("port"))=="53"}>={"UDP","TCP"} for r in eg) sys.exit(0 if ok else 1)'"""), ], solution=""" cat </dev/null 2>&1 $K -n booking-tls delete cm booking-nginx-config --ignore-not-found >/dev/null 2>&1 $K -n booking-tls delete secret booking-tls-cert --ignore-not-found >/dev/null 2>&1 $K -n booking-tls delete svc booking-nginx-svc --ignore-not-found >/dev/null 2>&1 d=$(mktemp -d) openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=booking.example.com" \ -keyout "$d/tls.key" -out "$d/tls.crt" >/dev/null 2>&1 $K -n booking-tls create secret tls booking-tls-cert --cert="$d/tls.crt" --key="$d/tls.key" >/dev/null rm -rf "$d" $K -n booking-tls create cm booking-nginx-config --from-literal=default.conf='server { listen 443 ssl; server_name booking.example.com; ssl_certificate /etc/nginx/tls/tls.crt; ssl_certificate_key /etc/nginx/tls/tls.key; ssl_protocols TLSv1.2 TLSv1.3; location / { return 200 "booking ok\n"; } }' >/dev/null $K apply -f - >/dev/null <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: {name: booking-nginx, namespace: booking-tls} spec: replicas: 1 selector: {matchLabels: {app: booking-nginx}} template: metadata: {labels: {app: booking-nginx}} spec: volumes: - {name: conf, configMap: {name: booking-nginx-config}} - {name: certs, secret: {secretName: booking-tls-cert}} containers: - name: web image: nginx:1.27 ports: [{containerPort: 443}] volumeMounts: - {name: conf, mountPath: /etc/nginx/conf.d} - {name: certs, mountPath: /etc/nginx/tls} --- apiVersion: v1 kind: Service metadata: {name: booking-nginx-svc, namespace: booking-tls} spec: type: NodePort selector: {app: booking-nginx} ports: [{port: 443, targetPort: 443, nodePort: 30443}] EOF $K -n booking-tls rollout status deploy booking-nginx --timeout=120s >/dev/null 2>&1 """, checks=[ C("the ConfigMap allows TLSv1.3 only", 3, r""" c=$(j -n booking-tls cm booking-nginx-config '{.data.default\.conf}') echo "$c" | grep -q 'ssl_protocols.*TLSv1\.3' && ! echo "$c" | grep -q 'TLSv1\.2'"""), # rollout restart (restartedAt annotation) or deleting the pods - either way every running # pod must be younger than the last write to the ConfigMap C("the pods were restarted to pick the change up", 2, r""" j -n booking-tls deploy booking-nginx '{.spec.template.metadata.annotations}' | grep -q 'restartedAt' && exit 0 cm_t=$($K -n booking-tls get cm booking-nginx-config -o json --show-managed-fields 2>/dev/null | python3 -c 'import sys,json; print(max(m.get("time","") for m in json.load(sys.stdin)["metadata"].get("managedFields",[])))') ts=$(j -n booking-tls pod -l app=booking-nginx '{.items[*].metadata.creationTimestamp}'); [ -n "$cm_t" ] && [ -n "$ts" ] || exit 1 for t in $ts; do [[ "$t" > "$cm_t" ]] || exit 1; done"""), C("ssl-line.txt records the directive", 2, r""" f="$WS/q84/ssl-line.txt" grep -q 'ssl_protocols' "$f" 2>/dev/null && grep -q 'TLSv1\.3' "$f" && ! grep -q 'TLSv1\.2' "$f" """), C("TLS 1.3 is accepted and TLS 1.2 is refused", 3, r""" for i in $(seq 1 20); do [ "$(j -n booking-tls deploy booking-nginx '{.status.readyReplicas}')" = 1 ] && break; sleep 5; done np=$(j -n booking-tls svc booking-nginx-svc '{.spec.ports[0].nodePort}') [ -n "$np" ] || exit 1 # Probe from a node, not from this machine: under rootless podman the kind node IPs are not # routable from the host, so a host-side openssl just hangs. Every node answers its own NodePort. nx "$W1" "echo | timeout 10 openssl s_client -connect 127.0.0.1:$np -tls1_3 >/dev/null 2>&1" /dev/null 2>&1" ssl_protocols TLSv1.3; k -n booking-tls rollout restart deploy booking-nginx k -n booking-tls rollout status deploy booking-nginx echo 'ssl_protocols TLSv1.3;' > {WS}/q84/ssl-line.txt # verify on the host, which is a node itself (the Service is published as NodePort 30443): # echo | openssl s_client -connect 127.0.0.1:30443 -tls1_3 # connects # echo | openssl s_client -connect 127.0.0.1:30443 -tls1_2 # alert: protocol version # A ConfigMap mounted as a volume is updated in place by the kubelet, but nginx only reads its # config at startup - so the file changes and the running server does not. `rollout restart` # adds a kubectl.kubernetes.io/restartedAt annotation to the pod template, which is a real # template change and therefore a real rollout, without you editing the spec by hand. """, ) # ------------------------------------------------------------------ 85 ---- task( id=85, domain="TSHOOT", title="Investigate vehicle tracker health", task=""" Namespace `vehicle-track` has a misbehaving workload. Collect evidence: 1. List **all** pods in **all** namespaces sorted by restart count, and save the output to `{WS}/q85/most-restarts.txt`. 2. Save the last 50 log lines from the `app=vehicle-tracker` pods to `{WS}/q85/logs.txt`. 3. Save the Warning events from `vehicle-track`, sorted by time, to `{WS}/q85/warnings.txt`. """, setup=NS + r""" mkns vehicle-track rm -rf "$WS/q85"; mkdir -p "$WS/q85" $K -n vehicle-track delete deploy vehicle-tracker --ignore-not-found --wait=true >/dev/null 2>&1 $K apply -f - >/dev/null <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: {name: vehicle-tracker, namespace: vehicle-track} spec: replicas: 1 selector: {matchLabels: {app: vehicle-tracker}} template: metadata: {labels: {app: vehicle-tracker}} spec: containers: - name: tracker image: busybox:1.36 command: [sh, -c, "echo tracker starting; echo gps fix lost; sleep 12; exit 1"] EOF """, checks=[ C("most-restarts.txt is a sorted all-namespace pod listing", 3, r""" f="$WS/q85/most-restarts.txt"; [ -s "$f" ] || exit 1 grep -qi 'NAMESPACE' "$f" && grep -q 'kube-system' "$f" && grep -q 'vehicle-track' "$f" \ && [ "$(wc -l < "$f")" -ge 5 ]"""), C("logs.txt holds the tracker's log output", 2, r""" grep -q 'tracker starting\|gps fix lost' "$WS/q85/logs.txt" 2>/dev/null"""), C("warnings.txt holds Warning events from the namespace", 3, r""" f="$WS/q85/warnings.txt"; [ -s "$f" ] || exit 1 grep -q 'Warning' "$f" && ! grep -q '^Normal' "$f" """), ], solution=""" k get pods -A --sort-by='.status.containerStatuses[0].restartCount' > {WS}/q85/most-restarts.txt k -n vehicle-track logs -l app=vehicle-tracker --tail=50 > {WS}/q85/logs.txt k -n vehicle-track get events --field-selector type=Warning --sort-by='.lastTimestamp' > {WS}/q85/warnings.txt # --sort-by takes a JSONPath into the object and sorts ascending, so the worst offenders land at # the BOTTOM of that first file. `logs -l` reads every pod matching the label, which is what you # want for a Deployment whose pod name keeps changing under a CrashLoopBackOff. """, ) QUESTIONS = Q