Task ids used to jump 1-43 / 101-124 / 401-418 / 501-517. They now run
contiguously in the same order:
1-43 unchanged (base and extra)
44-67 hard pool (was 101-124)
68-85 Simulator Set C (was 401-418)
86-102 Simulator Set D (was 501-517)
Every reference moved with its task: ids, solution keys, workspace paths
({WS}/qNN and the graders' hidden .qNN-* files), temp paths, the one
hardcoded id in code (Q56's derived canary weight), and Q-number
cross-references in the harness, README and the explainer. Each task's
paths were checked to carry only its own new number. History entries and
the local session state were remapped so past attempts stay attached to the
same tasks; retired set A/B ids keep their old numbers and match nothing.
weak-spots is regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mhxm4rsUfuE5HoytYbu2YW
1309 lines
63 KiB
Python
1309 lines
63 KiB
Python
"""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: ./exam sim c
|
|
"""
|
|
from tracks.cka.common 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; done
|
|
""",
|
|
checks=[
|
|
C("PV geofence-pv has the exact requested spec", 3, r"""
|
|
p="$K get pv geofence-pv"
|
|
eq "$($p -o jsonpath='{.spec.capacity.storage}' 2>/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 <<EOF | k apply -f -
|
|
apiVersion: v1
|
|
kind: PersistentVolume
|
|
metadata: {name: geofence-pv}
|
|
spec:
|
|
capacity: {storage: 250Mi}
|
|
accessModes: [ReadWriteOnce]
|
|
hostPath: {path: /data/geofence}
|
|
storageClassName: manual
|
|
persistentVolumeReclaimPolicy: Retain
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata: {name: geofence-pvc, namespace: geo-fence}
|
|
spec:
|
|
accessModes: [ReadWriteOnce]
|
|
resources: {requests: {storage: 250Mi}}
|
|
storageClassName: manual
|
|
volumeName: geofence-pv
|
|
---
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata: {name: geofence-reader, namespace: geo-fence}
|
|
spec:
|
|
volumes: [{name: geo, persistentVolumeClaim: {claimName: geofence-pvc}}]
|
|
containers:
|
|
- name: reader
|
|
image: busybox:1.36
|
|
command: [sh, -c, "ls /geo-data && sleep 3600"]
|
|
volumeMounts: [{name: geo, mountPath: /geo-data}]
|
|
EOF
|
|
k -n geo-fence get pvc
|
|
# volumeName pins the claim to one PV by name. Without it the binder picks ANY PV whose capacity,
|
|
# accessModes and storageClassName match - which on a busy cluster is rarely the one you meant.
|
|
""",
|
|
)
|
|
|
|
# ------------------------------------------------------------------ 69 ----
|
|
task(
|
|
id=69, domain="ARCH", title="Render a chart, then install it",
|
|
task="""
|
|
A chart is at `{WS}/q69/chart`.
|
|
|
|
1. Render the chart **without installing it** as release name `ride-argocd`, namespace
|
|
`ride-gitops`, with `replicaCount=3`, saving the rendered manifests to
|
|
`{WS}/q69/manifest.yaml`.
|
|
2. Then install that same chart into Namespace `ride-gitops` (create it) as release
|
|
`ride-argocd` with `replicaCount=3`.
|
|
3. Save the output of listing the releases in that Namespace to `{WS}/q69/releases.txt`.
|
|
""",
|
|
setup=r"""
|
|
rm -rf "$WS/q69"; mkdir -p "$WS/q69/chart/templates"
|
|
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\|<html' "$WS/q71/test-result.txt" 2>/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
|
|
done
|
|
$K apply -f - >/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
|
|
done; exit 1"""),
|
|
C("Deployment ride-archive now uses the new claim", 2, r"""
|
|
j -n ride-history deploy ride-archive '{.spec.template.spec.volumes[*].persistentVolumeClaim.claimName}' | grep -qw ride-archive-pvc-new"""),
|
|
C("Deployment pods are Running again", 1, r"""
|
|
for i in $(seq 1 20); do [ "$(j -n ride-history deploy ride-archive '{.status.availableReplicas}')" -ge 1 ] 2>/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 <<EOF | k apply -f -
|
|
apiVersion: v1
|
|
kind: PersistentVolume
|
|
metadata: {name: ride-archive-pv-new}
|
|
spec:
|
|
capacity: {storage: 500Mi}
|
|
accessModes: [ReadWriteOnce]
|
|
hostPath: {path: /data/ride-archive-new}
|
|
storageClassName: manual
|
|
persistentVolumeReclaimPolicy: Retain
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata: {name: ride-archive-pvc-new, namespace: ride-history}
|
|
spec:
|
|
accessModes: [ReadWriteOnce]
|
|
resources: {requests: {storage: 500Mi}}
|
|
storageClassName: manual
|
|
volumeName: ride-archive-pv-new
|
|
EOF
|
|
k -n ride-history scale deploy ride-archive --replicas=0 # release the RWO claim first
|
|
cat <<EOF | k apply -f -
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata: {name: data-migrator, namespace: ride-history}
|
|
spec:
|
|
restartPolicy: Never
|
|
volumes:
|
|
- {name: old, persistentVolumeClaim: {claimName: ride-archive-pvc}}
|
|
- {name: new, persistentVolumeClaim: {claimName: ride-archive-pvc-new}}
|
|
containers:
|
|
- name: migrator
|
|
image: busybox:1.36
|
|
command: [sh, -c, "cp -a /old-data/. /new-data/ && echo migration-complete && sleep 3600"]
|
|
volumeMounts:
|
|
- {name: old, mountPath: /old-data}
|
|
- {name: new, mountPath: /new-data}
|
|
EOF
|
|
k -n ride-history logs data-migrator # migration-complete
|
|
k -n ride-history patch deploy ride-archive --type=json \\
|
|
-p '[{"op":"replace","path":"/spec/template/spec/volumes/0/persistentVolumeClaim/claimName","value":"ride-archive-pvc-new"}]'
|
|
k -n ride-history scale deploy ride-archive --replicas=1
|
|
# ReadWriteOnce is per-node, but a second pod on the same node still cannot mount a claim that a
|
|
# running pod holds - hence the scale to 0 first. `cp -a /old/. /new/` copies the CONTENTS
|
|
# (including dotfiles) rather than nesting the directory inside the target.
|
|
""",
|
|
)
|
|
|
|
# ------------------------------------------------------------------ 73 ----
|
|
task(
|
|
id=73, domain="TSHOOT", title="Fix ETA service connectivity",
|
|
task="""
|
|
In Namespace `eta-service`, Deployment `eta-calculator` is healthy but Service `eta-backend`
|
|
returns nothing. There are **two** faults in the Service.
|
|
|
|
Fix the Service so it has active endpoints pointing at the deployment's pods. Do not change the
|
|
Deployment.
|
|
""",
|
|
setup=NS + r"""
|
|
mkns eta-service
|
|
$K -n eta-service delete svc eta-backend --ignore-not-found >/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
|
|
$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 <<EOF | k apply -f -
|
|
apiVersion: gateway.networking.k8s.io/v1
|
|
kind: Gateway
|
|
metadata: {name: fleet-gateway, namespace: fleet-ops}
|
|
spec:
|
|
gatewayClassName: fleet-gateway-class
|
|
listeners:
|
|
- name: http
|
|
protocol: HTTP
|
|
port: 80
|
|
hostname: fleet.example.com
|
|
---
|
|
apiVersion: gateway.networking.k8s.io/v1
|
|
kind: HTTPRoute
|
|
metadata: {name: fleet-route, namespace: fleet-ops}
|
|
spec:
|
|
parentRefs: [{name: fleet-gateway}]
|
|
hostnames: [fleet.example.com]
|
|
rules:
|
|
- matches:
|
|
- headers: [{type: Exact, name: X-Fleet-Version, value: canary}]
|
|
backendRefs: [{name: fleet-dashboard-v2, port: 80}]
|
|
- matches:
|
|
- path: {type: PathPrefix, value: /}
|
|
backendRefs: [{name: fleet-dashboard-v1, port: 80}]
|
|
EOF
|
|
# Order matters: HTTPRoute picks the most specific match, and a bare PathPrefix "/" matches
|
|
# everything - so the header rule must be able to win. Listing it first also keeps it readable.
|
|
""",
|
|
)
|
|
|
|
# ------------------------------------------------------------------ 75 ----
|
|
task(
|
|
id=75, domain="ARCH", title="Cluster-wide read access for the auth team",
|
|
task="""
|
|
The rider auth team needs a read-only identity that works in **every** Namespace.
|
|
|
|
1. Create ServiceAccount `auth-auditor` in Namespace `rider-auth`.
|
|
2. Create ClusterRole `auth-cluster-viewer` granting only `get`, `list` and `watch` on
|
|
`pods`, `deployments`, `services` and `events`.
|
|
3. Bind it cluster-wide to that ServiceAccount with ClusterRoleBinding `auth-viewer-binding`.
|
|
4. Verify the ServiceAccount can list pods in `kube-system` and write the single-word answer to
|
|
`{WS}/q75/auth-check.txt`.
|
|
""",
|
|
setup=NS + r"""
|
|
mkns rider-auth
|
|
rm -rf "$WS/q75"; mkdir -p "$WS/q75"
|
|
$K delete clusterrolebinding auth-viewer-binding --ignore-not-found >/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 && want=yes || want=no ;;
|
|
*) want=no ;;
|
|
esac
|
|
grep -qix "$want" "$WS/q77/enforced.txt" 2>/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 <<EOF | k apply -f -
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata: {name: default-deny-all, namespace: zero-trust}
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes: [Ingress, Egress]
|
|
EOF
|
|
k -n zero-trust exec cli -- wget -qO- -T3 http://srv >/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 <<EOF | k apply -f -
|
|
apiVersion: scheduling.k8s.io/v1
|
|
kind: PriorityClass
|
|
metadata: {name: rider-support-priority}
|
|
value: 1000000
|
|
globalDefault: false
|
|
description: Priority for rider support services
|
|
EOF
|
|
k -n rider-support patch deploy support-portal \\
|
|
-p '{"spec":{"template":{"spec":{"priorityClassName":"rider-support-priority"}}}}'
|
|
k -n rider-support rollout status deploy support-portal
|
|
k -n rider-support get pod -l app=support-portal -o jsonpath='{.items[0].spec.priority}'
|
|
# The admission controller copies the class's value into pod.spec.priority at creation time, so
|
|
# existing pods keep their old priority until they are recreated - patching the template does
|
|
# that for you. globalDefault: true would apply the class to every pod without one, cluster-wide.
|
|
""",
|
|
)
|
|
|
|
# ------------------------------------------------------------------ 79 ----
|
|
task(
|
|
id=79, domain="WORK", title="Surge pricing configuration",
|
|
task="""
|
|
In Namespace `surge-pricing`:
|
|
|
|
1. Create ConfigMap `surge-config` with `PRICING_MODE=dynamic`, `MAX_MULTIPLIER=3.5`,
|
|
`REGION=us-east-1`.
|
|
2. Create Secret `surge-db-secret` with keys `DB_HOST`, `DB_USER` and `DB_PASSWORD` (any values).
|
|
3. Deployment `surge-engine` must receive **every** key from both as environment variables, using
|
|
`envFrom` — not one `env` entry per key.
|
|
4. The same ConfigMap must **also** be mounted as a volume at `/etc/surge-config`.
|
|
|
|
Pods must be Running with the variables readable inside the container.
|
|
""",
|
|
setup=NS + r"""
|
|
mkns surge-pricing
|
|
$K -n surge-pricing delete cm surge-config --ignore-not-found >/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 | python3 -c 'import sys,json;print(json.load(sys.stdin)["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'
|
|
apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.34.1-1.1 && apt-mark hold kubeadm
|
|
kubeadm upgrade plan
|
|
kubeadm upgrade apply v1.34.1 -y
|
|
kubectl drain <control-plane> --ignore-daemonsets
|
|
apt-mark unhold kubelet kubectl && apt-get install -y kubelet=1.34.1-1.1 kubectl=1.34.1-1.1 && apt-mark hold kubelet kubectl
|
|
systemctl daemon-reload
|
|
systemctl restart kubelet
|
|
kubectl uncordon <control-plane>
|
|
EOF
|
|
echo 'kubeadm upgrade node' > {WS}/q80/worker.txt
|
|
# 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 <<EOF | k apply -f -
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata: {name: fare-data-pvc, namespace: fare-engine}
|
|
spec:
|
|
accessModes: [ReadWriteOnce]
|
|
resources: {requests: {storage: 100Mi}}
|
|
storageClassName: standard
|
|
EOF
|
|
k -n fare-engine describe pod -l app=fare-calculator # 0/4 nodes match node affinity disk in [nvme]
|
|
k get nodes --show-labels | grep -c 'disk=nvme' # 0
|
|
# Either label a node to satisfy the rule, or drop the rule. Labelling is the smaller change:
|
|
k label node <a-worker> 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 <<EOF | k apply -f -
|
|
apiVersion: autoscaling/v2
|
|
kind: HorizontalPodAutoscaler
|
|
metadata: {name: route-planner-hpa, namespace: route-planner}
|
|
spec:
|
|
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: route-planner}
|
|
minReplicas: 1
|
|
maxReplicas: 6
|
|
metrics:
|
|
- type: Resource
|
|
resource:
|
|
name: memory
|
|
target: {type: Utilization, averageUtilization: 70}
|
|
behavior:
|
|
scaleUp:
|
|
policies:
|
|
- {type: Pods, value: 2, periodSeconds: 60}
|
|
EOF
|
|
k -n route-planner get hpa route-planner-hpa
|
|
# `kubectl autoscale` only emits CPU targets and no behavior block, so memory + behavior means
|
|
# writing the v2 YAML by hand. Utilisation is a percentage OF THE REQUEST, so the Deployment must
|
|
# declare a memory request or the HPA reports <unknown> 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 <<EOF | k apply -f -
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata: {name: safety-api-policy, namespace: safety-monitor}
|
|
spec:
|
|
podSelector: {matchLabels: {app: safety-api}}
|
|
policyTypes: [Ingress, Egress]
|
|
ingress:
|
|
- from: [{podSelector: {matchLabels: {app: safety-dashboard}}}]
|
|
ports: [{protocol: TCP, port: 8443}]
|
|
egress:
|
|
- to: [{podSelector: {matchLabels: {app: safety-db}}}]
|
|
ports: [{protocol: TCP, port: 5432}]
|
|
- ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}]
|
|
EOF
|
|
# The DNS rule deliberately has no `to:` - CoreDNS lives in kube-system, and an egress rule with
|
|
# only `ports` allows that port to anywhere. Naming Egress in policyTypes denies all outbound
|
|
# traffic by default, so forgetting DNS is what silently breaks every name lookup in the pod.
|
|
""",
|
|
)
|
|
|
|
# ------------------------------------------------------------------ 84 ----
|
|
task(
|
|
id=84, domain="ARCH", title="Enforce TLS 1.3 on the booking portal",
|
|
task="""
|
|
Namespace `booking-tls` runs Deployment `booking-nginx`, which serves HTTPS using the nginx
|
|
configuration in ConfigMap `booking-nginx-config`. It currently accepts TLS 1.2 **and** 1.3.
|
|
|
|
1. Change the ConfigMap so only TLS 1.3 is accepted.
|
|
2. Make the running pods pick up the change, without editing the Deployment's pod template by hand.
|
|
3. Write the nginx directive line you ended up with, as it appears in the ConfigMap, to
|
|
`{WS}/q84/ssl-line.txt`.
|
|
|
|
A TLS 1.2 handshake against the service must be refused afterwards.
|
|
""",
|
|
setup=NS + r"""
|
|
mkns booking-tls
|
|
rm -rf "$WS/q84"; mkdir -p "$WS/q84"
|
|
$K -n booking-tls delete deploy booking-nginx --ignore-not-found --wait=true >/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 || exit 1
|
|
nx "$W1" "echo | timeout 10 openssl s_client -connect 127.0.0.1:$np -tls1_2 >/dev/null 2>&1" </dev/null && exit 1
|
|
exit 0"""),
|
|
],
|
|
solution="""
|
|
k -n booking-tls edit cm booking-nginx-config
|
|
# ssl_protocols TLSv1.2 TLSv1.3; -> 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 from ON a node (the Service is published as NodePort 30443):
|
|
# {SHELL_W1}
|
|
# 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
|
|
# Probing from your workstation will just hang - rootless podman does not route the node IPs.
|
|
# 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
|