Compare commits
No commits in common. "f4f2a6bea99c7d593f61de58049d15af1d40a232" and "7fae1f2c36249e3419ecf7e14aa35bef4ccd41b5" have entirely different histories.
f4f2a6bea9
...
7fae1f2c36
@ -51,7 +51,7 @@ EOF
|
||||
| **Troubleshoot Node (Kubelet)** | `systemctl status kubelet` <br> `journalctl -u kubelet -e --no-pager` | Check `/etc/kubernetes/kubelet.conf` & `/var/lib/kubelet/config.yaml` |
|
||||
| **Control Plane Troubleshooting** | Inspect `/etc/kubernetes/manifests/` | Check `kube-apiserver.yaml`, `kube-scheduler.yaml`, `kube-controller-manager.yaml` |
|
||||
| **Etcd Backup** | `etcdctl snapshot save <file>` | `--endpoints=https://127.0.0.1:2379 --cacert=... --cert=... --key=...` |
|
||||
| **Etcd Restore** | `etcdutl snapshot restore <file>` | `--data-dir=/var/lib/etcd-backup` (update `etcd.yaml` volume hostPath). `etcdctl snapshot restore` is gone from etcd 3.6+ |
|
||||
| **Etcd Restore** | `etcdctl snapshot restore <file>` | `--data-dir=/var/lib/etcd-backup` (update `etcd.yaml` volume hostPath) |
|
||||
| **Upgrade Node (Control Plane / Worker)** | `apt-get update && apt-get install -y --allow-change-held-packages kubeadm=1.31.x-1.1` | `kubeadm upgrade plan` -> `kubeadm upgrade apply` -> upgrade `kubelet`/`kubectl` -> `systemctl restart kubelet` |
|
||||
|
||||
---
|
||||
@ -98,16 +98,12 @@ ETCDCTL_API=3 etcdctl \
|
||||
|
||||
### Restore:
|
||||
```bash
|
||||
etcdutl snapshot restore /opt/snapshot-pre-boot.db \
|
||||
--data-dir=/var/lib/etcd-from-backup
|
||||
ETCDCTL_API=3 etcdctl \
|
||||
--data-dir=/var/lib/etcd-from-backup \
|
||||
snapshot restore /opt/snapshot-pre-boot.db
|
||||
```
|
||||
Then edit `/etc/kubernetes/manifests/etcd.yaml` to update the hostPath volume for `etcd-data` to `/var/lib/etcd-from-backup`.
|
||||
|
||||
Restore is an offline file operation, so it needs no endpoints or certificates - and it lives in
|
||||
`etcdutl`, not `etcdctl`: `etcdctl snapshot restore` was deprecated in etcd 3.5 and is gone from
|
||||
3.6+ (this cluster runs 3.7, where it fails with `unknown flag: --data-dir`). Check first with
|
||||
`etcdctl version`; on a 3.5 node the old `ETCDCTL_API=3 etcdctl snapshot restore` still works.
|
||||
|
||||
---
|
||||
|
||||
## 5. Official Kubernetes Documentation URLs Allowed in CKA
|
||||
|
||||
@ -10,36 +10,36 @@ This bank mirrors the real CKA curriculum weights (25/15/20/10/30% across the fi
|
||||
|---|---|---|
|
||||
| StorageClass and Dynamic Provisioning | Storage | Q16 |
|
||||
| PV Access Modes | Storage | Q15 |
|
||||
| Volume Can't Be Mounted | Storage | Q35, Q58 (troubleshooting a binding/mount failure) |
|
||||
| PVC Data Migration | Storage | Q59 |
|
||||
| Deployment and StatefulSet Rollouts | Workloads & Scheduling | Q07, Q51 |
|
||||
| Volume Can't Be Mounted | Storage | Q35, Q115 (troubleshooting a binding/mount failure) |
|
||||
| PVC Data Migration | Storage | Q116 |
|
||||
| Deployment and StatefulSet Rollouts | Workloads & Scheduling | Q07, Q108 |
|
||||
| Init Container and Secret | Workloads & Scheduling | Q28 + Q10/Q29 (init container *and* a broken Secret reference, combined) |
|
||||
| DaemonSet HostPath Configurator | Workloads & Scheduling | Q26 |
|
||||
| Node Selector and Scheduling | Workloads & Scheduling | Q09 (this bank uses required nodeAffinity instead of nodeSelector — worth doing both) |
|
||||
| Node Taints and Tolerations | Workloads & Scheduling | Q25, Q50 |
|
||||
| Scheduling Pod Affinity | Workloads & Scheduling | *no direct equivalent here* — this bank only exercises anti-affinity (Q50); do this one to cover positive affinity too |
|
||||
| Scheduling Pod Anti Affinity | Workloads & Scheduling | Q50 |
|
||||
| Scheduling Priority | Workloads & Scheduling | Q24, Q50 |
|
||||
| NetworkPolicy Isolation | Services & Networking | Q11, Q54 |
|
||||
| Troubleshoot NetworkPolicies | Services & Networking | Q57 (a blocking NetworkPolicy is one of its three faults) |
|
||||
| Node Taints and Tolerations | Workloads & Scheduling | Q25, Q107 |
|
||||
| Scheduling Pod Affinity | Workloads & Scheduling | *no direct equivalent here* — this bank only exercises anti-affinity (Q107); do this one to cover positive affinity too |
|
||||
| Scheduling Pod Anti Affinity | Workloads & Scheduling | Q107 |
|
||||
| Scheduling Priority | Workloads & Scheduling | Q24, Q107 |
|
||||
| NetworkPolicy Isolation | Services & Networking | Q11, Q111 |
|
||||
| Troubleshoot NetworkPolicies | Services & Networking | Q114 (a blocking NetworkPolicy is one of its three faults) |
|
||||
| App Goes Public | Services & Networking | Q12 (NodePort here; LoadBalancer+autoscale adds Q23's HPA angle) |
|
||||
| Gateway API Setup | Services & Networking | Q22, Q56 |
|
||||
| CoreDNS Misconfigured | Services & Networking | Q37, Q57 |
|
||||
| TLS Secret and HTTPS | Services & Networking | Q55 |
|
||||
| Apiserver Crash | Troubleshooting | Q66 |
|
||||
| Apiserver Misconfigured | Troubleshooting | Q66 |
|
||||
| Gateway API Setup | Services & Networking | Q22, Q113 |
|
||||
| CoreDNS Misconfigured | Services & Networking | Q37, Q114 |
|
||||
| TLS Secret and HTTPS | Services & Networking | Q112 |
|
||||
| Apiserver Crash | Troubleshooting | Q123 |
|
||||
| Apiserver Misconfigured | Troubleshooting | Q123 |
|
||||
| Deployment Scaling Issue | Troubleshooting | Q23 |
|
||||
| Application Multi Container Issue | Troubleshooting | Q08, Q52 (troubleshooting angle on a multi-container pod) |
|
||||
| Applications Misconfigured | Troubleshooting | Q18, Q62 |
|
||||
| Components Misconfigured | Troubleshooting | Q20, Q38, Q61 |
|
||||
| RBAC for CronJob | Cluster Architecture | Q01/Q44's RBAC pattern, applied to a CronJob's ServiceAccount specifically |
|
||||
| RBAC User Permissions | Cluster Architecture | Q06, Q33, Q47 |
|
||||
| Operator Setup and CRD | Cluster Architecture | Q04, Q34, Q49 (CRD half only — this bank never writes an actual operator/controller) |
|
||||
| Application Multi Container Issue | Troubleshooting | Q08, Q109 (troubleshooting angle on a multi-container pod) |
|
||||
| Applications Misconfigured | Troubleshooting | Q18, Q119 |
|
||||
| Components Misconfigured | Troubleshooting | Q20, Q38, Q118 |
|
||||
| RBAC for CronJob | Cluster Architecture | Q01/Q101's RBAC pattern, applied to a CronJob's ServiceAccount specifically |
|
||||
| RBAC User Permissions | Cluster Architecture | Q06, Q33, Q104 |
|
||||
| Operator Setup and CRD | Cluster Architecture | Q04, Q34, Q106 (CRD half only — this bank never writes an actual operator/controller) |
|
||||
| Operator Namespace Creator | Cluster Architecture | *no equivalent here* — this bank doesn't cover writing controller logic (arguably CKAD/dev territory more than CKA) |
|
||||
| Static Pod Move | Cluster Architecture | Q05, Q46 |
|
||||
| Cluster Setup and Node Join | Cluster Architecture | Q40 (token) + Q67 (independently verifying the CA discovery hash and real in-cluster API endpoint) — the token/crypto half only, see gap note |
|
||||
| Static Pod Move | Cluster Architecture | Q05, Q103 |
|
||||
| Cluster Setup and Node Join | Cluster Architecture | Q40 (token) + Q124 (independently verifying the CA discovery hash and real in-cluster API endpoint) — the token/crypto half only, see gap note |
|
||||
| Cluster Upgrade | Cluster Architecture | *no equivalent here* — see gap note below |
|
||||
| Cluster Certificate Management | Cluster Architecture | Q06, Q65 |
|
||||
| Cluster Certificate Management | Cluster Architecture | Q06, Q122 |
|
||||
|
||||
### Real gaps worth knowing about
|
||||
|
||||
@ -50,8 +50,8 @@ Two topics from Killercoda's list still have **no faithful equivalent in this si
|
||||
|
||||
Two other gaps this bank previously had are now closed:
|
||||
|
||||
- **Apiserver Crash / Apiserver Misconfigured** → **Q66**. Same diagnostic method as the existing scheduler/controller-manager failures (Q20, Q38, Q61), just applied to kube-apiserver itself — with the added twist that *every* `kubectl` command is unusable until you fix it, so you have to work from the node directly (`crictl`, `journalctl`) rather than leaning on `kubectl describe`.
|
||||
- **Cluster Setup and Node Join** → partially closed by **Q67**. A full `kubeadm init`/`join` end-to-end on a fresh node isn't safe to automate here (it would mean deleting a live Node object on your shared 3-worker cluster, which several other questions pin pods to by label/taint) — but Q67 drills the cryptographic half a real join actually depends on: computing the CA's discovery hash yourself and finding the real in-cluster API endpoint, rather than trusting whatever `kubeadm token create --print-join-command` or your local kubeconfig hands you.
|
||||
- **Apiserver Crash / Apiserver Misconfigured** → **Q123**. Same diagnostic method as the existing scheduler/controller-manager failures (Q20, Q38, Q118), just applied to kube-apiserver itself — with the added twist that *every* `kubectl` command is unusable until you fix it, so you have to work from the node directly (`crictl`, `journalctl`) rather than leaning on `kubectl describe`.
|
||||
- **Cluster Setup and Node Join** → partially closed by **Q124**. A full `kubeadm init`/`join` end-to-end on a fresh node isn't safe to automate here (it would mean deleting a live Node object on your shared 3-worker cluster, which several other questions pin pods to by label/taint) — but Q124 drills the cryptographic half a real join actually depends on: computing the CA's discovery hash yourself and finding the real in-cluster API endpoint, rather than trusting whatever `kubeadm token create --print-join-command` or your local kubeconfig hands you.
|
||||
|
||||
The exam-day skill this table is really pointing at, regardless of which platform you practice on: the diagnostic *method* is identical everywhere — read the actual error (`describe`, `logs`, `journalctl -u kubelet`), don't guess from memory, and verify with the tool the grader would use (`auth can-i`, `get endpointslices`, `rollout status`) rather than assuming a manifest applied cleanly means it's actually working.
|
||||
|
||||
@ -308,7 +308,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q44 · [HARD] Exact RBAC with an Existing Over-Grant
|
||||
### Q101 · [HARD] Exact RBAC with an Existing Over-Grant
|
||||
- **Concept**: This is Q01's lesson turned adversarial: someone already bound `cluster-admin` to the ServiceAccount "temporarily" — a stand-in for the all-too-common real-world over-grant. Least privilege means actively auditing and revoking excess access, not just adding what's missing; the grader checks both the positive grants **and** that nothing broader survives.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -329,7 +329,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q45 · [HARD] etcd Restore
|
||||
### Q102 · [HARD] etcd Restore
|
||||
- **Concept**: Restoring etcd doesn't overwrite the live data directory in place — `etcdutl snapshot restore` materializes a **brand-new** data directory from the snapshot, and you then repoint the etcd static pod's `hostPath` volume at that new directory. The kubelet notices the manifest change and recreates the static pod, which comes up serving whatever state existed at snapshot time — so anything created after the snapshot (like the ConfigMap this task deletes and recreates) reverts, and this is the entire point of the exercise.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -349,7 +349,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q46 · [HARD] Static Pod on a Relocated Manifest Path
|
||||
### Q103 · [HARD] Static Pod on a Relocated Manifest Path
|
||||
- **Concept**: Same mechanism as Q05, with the deliberate trap that `staticPodPath` has been changed on this node — dropping a manifest into the default `/etc/kubernetes/manifests` here does nothing, because that kubelet isn't watching it anymore. This tests whether you actually check the config instead of assuming the default.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -372,7 +372,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q47 · [HARD] Self-Contained Kubeconfig Creation
|
||||
### Q104 · [HARD] Self-Contained Kubeconfig Creation
|
||||
- **Concept**: A kubeconfig can reference certs either by file path (`certificate-authority`, `client-certificate`) or by embedding their base64 contents directly (`certificate-authority-data`, `client-certificate-data`). Only the embedded form is truly **portable** — a path-based kubeconfig breaks the moment it's copied to a machine without those exact files at those exact paths, which is exactly what "self-contained" is testing.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -390,7 +390,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
k -n %{ns} create rolebinding %{user}-edit --clusterrole=edit --user=%{user}
|
||||
|
||||
KC={WS}/q47/%{user}.kubeconfig
|
||||
KC={WS}/q104/%{user}.kubeconfig
|
||||
k config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt
|
||||
SERVER=$(k config view --minify -o jsonpath='{.clusters[0].cluster.server}')
|
||||
|
||||
@ -405,11 +405,11 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q48 · [HARD] Kustomize with Generators and Patches
|
||||
### Q105 · [HARD] Kustomize with Generators and Patches
|
||||
- **Concept**: This combines every non-trivial Kustomize feature at once: a **strategic-merge patch** (`patches: [{path: replicas.yaml}]`) for a simple field override, a **JSON 6902 patch** for a structural addition that strategic-merge can't express well (injecting a whole `envFrom` array element), and a `configMapGenerator`, which — critically — appends a content hash to the generated name and then **automatically rewrites every reference to that ConfigMap** elsewhere in the same kustomization. That's what makes hashed-ConfigMap rollouts safe: change the literal, get a new hash, get a new name, and Kubernetes treats it as a completely new object — pods roll to pick it up instead of silently reading stale data from a ConfigMap edited in place.
|
||||
- **Solution**:
|
||||
```bash
|
||||
mkdir -p {WS}/q48/overlays/prod && cd {WS}/q48/overlays/prod
|
||||
mkdir -p {WS}/q105/overlays/prod && cd {WS}/q105/overlays/prod
|
||||
|
||||
cat > replicas.yaml <<EOF
|
||||
apiVersion: apps/v1
|
||||
@ -446,7 +446,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q49 · [HARD] CRD with Validation and Defaults
|
||||
### Q106 · [HARD] CRD with Validation and Defaults
|
||||
- **Concept**: A CRD's OpenAPI v3 schema is enforced by the API server itself, before an object is ever persisted: `minimum`/`maximum` reject an out-of-range number at admission time, `required` forces a field to be present, and `default` retroactively fills in a value the caller left out. This is "structural schema" validation, and it's what separates a CRD from just being a loosely-typed blob in etcd.
|
||||
- **Solution**:
|
||||
```yaml
|
||||
@ -470,7 +470,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
# Step 3 — capture the rejection of an invalid one
|
||||
printf 'apiVersion: deploy.example.com/v1\nkind: %{kind}\nmetadata: {name: bad}\nspec: {replicas: 0, image: x}\n' \
|
||||
| k apply -f - 2> {WS}/q49/rejected.txt
|
||||
| k apply -f - 2> {WS}/q106/rejected.txt
|
||||
|
||||
# Step 4 — confirm defaulting
|
||||
printf 'apiVersion: deploy.example.com/v1\nkind: %{kind}\nmetadata: {name: defaulted}\nspec: {replicas: 2, image: nginx:1.27}\n' \
|
||||
@ -481,7 +481,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q65 · [HARD] Renew Control Plane Certificates
|
||||
### Q122 · [HARD] Renew Control Plane Certificates
|
||||
- **Concept**: kubeadm tracks every certificate it issued and can regenerate all of them from the existing cluster CA in one shot with `kubeadm certs renew all`. New certificate files on disk don't automatically get picked up by already-running static-pod processes — you have to force the kubelet to recreate those pods (moving manifests out and back is the classic trick) so each component re-reads its cert files on startup.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -498,7 +498,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q67 · [HARD] Prepare a Node Join Without Shell Access to the Control Plane
|
||||
### Q124 · [HARD] Prepare a Node Join Without Shell Access to the Control Plane
|
||||
- **Concept**: `kubeadm join` doesn't trust the control plane on faith — it verifies the server it's talking to by hashing the CA certificate's SubjectPublicKeyInfo with SHA-256 and comparing it against the `--discovery-token-ca-cert-hash` you supply, which is exactly the computation this task has you reproduce by hand instead of trusting whatever `kubeadm token create --print-join-command` prints. Separately, the address a *joining node* needs is the API server's real address on the cluster network — not the `127.0.0.1:<port>` your own kubeconfig uses, which only works because kind port-forwards that specifically for your host machine.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -727,7 +727,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
---
|
||||
|
||||
### Q28 · Init Containers
|
||||
- **Concept**: `initContainers` run to completion, in order, before any regular container starts — the standard pattern for "prepare something the main container needs," here using a shared `emptyDir` to hand off generated content from a setup step to the long-running server. Unlike native sidecars (Q52), a plain init container fully exits before the main container's process even begins.
|
||||
- **Concept**: `initContainers` run to completion, in order, before any regular container starts — the standard pattern for "prepare something the main container needs," here using a shared `emptyDir` to hand off generated content from a setup step to the long-running server. Unlike native sidecars (Q109), a plain init container fully exits before the main container's process even begins.
|
||||
- **Solution**:
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
@ -810,7 +810,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q50 · [HARD] Spread, Tolerate, Prioritise — Under a Quota
|
||||
### Q107 · [HARD] Spread, Tolerate, Prioritise — Under a Quota
|
||||
- **Concept**: This stacks four scheduling primitives that each solve a different problem: **pod anti-affinity** (`requiredDuringSchedulingIgnoredDuringExecution` on `topologyKey: kubernetes.io/hostname`) forces one replica per node by refusing to co-locate pods matching a label selector; a **toleration** lets pods land on an otherwise-repelling tainted node; **PriorityClass** affects preemption, not placement directly; and a **ResourceQuota** caps the namespace regardless of what the scheduler would otherwise allow — so the requests must be small enough to fit inside it.
|
||||
- **Solution**:
|
||||
```yaml
|
||||
@ -830,7 +830,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
- labelSelector: {matchLabels: {app: %{dep}}}
|
||||
topologyKey: kubernetes.io/hostname
|
||||
tolerations:
|
||||
- {key: %{key}, operator: Equal, value: "%{val}", effect: NoSchedule}
|
||||
- {key: %{key}, operator: {Equal, value: "%{val}", effect: NoSchedule}}
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:1.27
|
||||
@ -838,14 +838,12 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
```
|
||||
```bash
|
||||
k create priorityclass %{dep}-prio --value=50000
|
||||
# the seeded quota (pods: 2, requests.cpu: 250m) cannot hold 3 x 100m - raise BOTH, keep the quota
|
||||
k -n %{ns} patch quota %{ns}-quota -p '{"spec":{"hard":{"pods":"3","requests.cpu":"300m"}}}'
|
||||
```
|
||||
- **Gotchas**: with exactly 3 worker nodes and `replicas: 3` under required anti-affinity, there is zero slack — if the quota's `pods` cap is lower than 3, or CPU requests don't fit the quota's `requests.cpu` budget, pods stay `Pending` with no scheduling error beyond an event; check `kubectl -n %{ns} get quota` and size requests accordingly rather than raising replicas blindly.
|
||||
|
||||
---
|
||||
|
||||
### Q51 · [HARD] Rollback to a Specific Revision, Then Pause
|
||||
### Q108 · [HARD] Rollback to a Specific Revision, Then Pause
|
||||
- **Concept**: Every `kubectl rollout undo --to-revision=N` targets a specific historical **ReplicaSet** by its stored revision annotation, not just "the previous one" — useful when several bad releases have shipped in a row. `rollout pause` freezes a Deployment's controller from acting on template changes: you can edit `spec.template` afterward all you like (image, env, whatever) and the controller will **not** create a new ReplicaSet or touch running pods until you `resume` — a safe way to stage a change for later.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -863,7 +861,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q52 · [HARD] Native Sidecar with Probes and an Exact Report
|
||||
### Q109 · [HARD] Native Sidecar with Probes and an Exact Report
|
||||
- **Concept**: Kubernetes 1.28+ added **native sidecars**: an `initContainers` entry with `restartPolicy: Always` starts before the main containers (like any init container) but then keeps running for the pod's whole lifetime and is restarted on its own if it crashes — unlike a legacy init container, it never blocks pod readiness by "completing," and unlike a regular sidecar container it's guaranteed to start first. Combined with `terminationGracePeriodSeconds`, this also governs shutdown ordering: main containers get the grace period to exit first, and native sidecars are stopped only after.
|
||||
- **Solution**:
|
||||
```yaml
|
||||
@ -876,26 +874,26 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
- name: agent
|
||||
image: busybox:1.36
|
||||
restartPolicy: Always
|
||||
command: [sh, -c, "while true; do date >> /var/log/agent/agent.log; sleep 1; done"]
|
||||
volumeMounts: [{name: logs, mountPath: /var/log/agent}]
|
||||
command: [sh, -c, "while true; do date >> /var/log/agent.log; sleep 1; done"]
|
||||
volumeMounts: [{name: logs, mountPath: /var/log}]
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:1.27
|
||||
volumeMounts: [{name: logs, mountPath: /var/log/agent}] # NOT /var/log - see Gotchas
|
||||
volumeMounts: [{name: logs, mountPath: /var/log}]
|
||||
readinessProbe: {httpGet: {path: /, port: 80}, periodSeconds: 5}
|
||||
livenessProbe: {tcpSocket: {port: 80}}
|
||||
volumes: [{name: logs, emptyDir: {}}]
|
||||
```
|
||||
```bash
|
||||
k -n %{ns} get pod %{pod} -o jsonpath='{.spec.initContainers[*].name}{"\n"}{.spec.containers[*].name}' \
|
||||
| tr ' ' '\n' | grep . | sort > {WS}/q52/containers.txt
|
||||
k -n %{ns} get pod %{pod} -o jsonpath='{.status.podIP}' | tr -d '\n' > {WS}/q52/ip.txt
|
||||
| tr ' ' '\n' | grep . | sort > {WS}/q109/containers.txt
|
||||
k -n %{ns} get pod %{pod} -o jsonpath='{.status.podIP}' | tr -d '\n' > {WS}/q109/ip.txt
|
||||
```
|
||||
- **Gotchas**: never mount an emptyDir over `/var/log` in an nginx container — it hides nginx's own `/var/log/nginx` and nginx dies with `could not open error log file`, crash-looping forever. A volume mount **replaces** the directory's contents; mount a subdirectory instead. `restartPolicy: Always` goes **inside the initContainer entry itself**, not at the pod level — that per-container field is exactly what flips a normal init container into a native sidecar. The report file must list container names sorted, one per line, no header — matching the check's own `sort` byte-for-byte.
|
||||
- **Gotchas**: `restartPolicy: Always` goes **inside the initContainer entry itself**, not at the pod level — that per-container field is exactly what flips a normal init container into a native sidecar. The report file must list container names sorted, one per line, no header — matching the check's own `sort` byte-for-byte.
|
||||
|
||||
---
|
||||
|
||||
### Q53 · [HARD] Cluster Audit to Exact Files
|
||||
### Q110 · [HARD] Cluster Audit to Exact Files
|
||||
- **Concept**: "Produce exact files" tasks are a scripting exercise disguised as a Kubernetes question — the grader diffs your output against a canonical, sorted, header-free rendering of live cluster state, so the reliable path is always `-o json` piped through `jq`/`python3`, never hand-transcribing what `kubectl get` prints to the terminal.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -905,28 +903,28 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
for p in json.load(sys.stdin)["items"]:
|
||||
if p["metadata"]["namespace"]=="kube-system": continue
|
||||
if not p["spec"]["containers"][0].get("readinessProbe"):
|
||||
print(p["metadata"]["namespace"]+"/"+p["metadata"]["name"])' | sort > {WS}/q53/no-readiness.txt
|
||||
print(p["metadata"]["namespace"]+"/"+p["metadata"]["name"])' | sort > {WS}/q110/no-readiness.txt
|
||||
|
||||
# <node> <count> of Running pods per node, all namespaces
|
||||
k get pods -A -o json | python3 -c '
|
||||
import sys,json,collections
|
||||
c=collections.Counter(p["spec"].get("nodeName") for p in json.load(sys.stdin)["items"]
|
||||
if p["status"]["phase"]=="Running" and p["spec"].get("nodeName"))
|
||||
for n in sorted(c): print(n, c[n])' > {WS}/q53/per-node.txt
|
||||
for n in sorted(c): print(n, c[n])' > {WS}/q110/per-node.txt
|
||||
|
||||
# every distinct image running in kube-system
|
||||
k -n kube-system get pods -o jsonpath='{.items[*].spec.containers[*].image}' \
|
||||
| tr ' ' '\n' | sort -u > {WS}/q53/images.txt
|
||||
| tr ' ' '\n' | sort -u > {WS}/q110/images.txt
|
||||
```
|
||||
- **Gotchas**: no header lines, no trailing commentary — the checker does a literal `sort`-then-compare against each file's contents. Excluding `kube-system` from the first file but restricting *to* `kube-system` in the third is easy to flip by accident; re-read exactly which file wants which scope before running the one-liners.
|
||||
|
||||
---
|
||||
|
||||
### Q64 · [HARD] The Context's Namespace
|
||||
### Q121 · [HARD] The Context's Namespace
|
||||
- **Concept**: A kubeconfig **context** bundles a cluster, a user, and a default namespace (`contexts[].context.namespace`) — any command run without an explicit `-n`/`--namespace` inherits that default, falling back to literal `default` only if the context doesn't set one. `kubectl config view --minify` collapses the kubeconfig down to just the active context, which is the safe way to read that default without assuming it's `default`.
|
||||
- **Solution**:
|
||||
```bash
|
||||
k config view --minify -o jsonpath='{..namespace}' > {WS}/q64/ns.txt
|
||||
k config view --minify -o jsonpath='{..namespace}' > {WS}/q121/ns.txt
|
||||
|
||||
# No -n anywhere below — everything inherits the context's namespace
|
||||
k create deploy %{dep} --image=busybox:1.36 --replicas=2 -- sleep 3600
|
||||
@ -1095,14 +1093,13 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
externalName: %{ext}
|
||||
```
|
||||
```bash
|
||||
# full name: busybox's nslookup applies no search domains, so the short form returns NXDOMAIN
|
||||
k -n %{ns} exec %{sts}-0 -- nslookup %{sts}-0.%{sts}.%{ns}.svc.cluster.local > {WS}/q43/pod0.txt
|
||||
k -n %{ns} exec %{sts}-0 -- nslookup %{sts}-0.%{sts} > {WS}/q43/pod0.txt
|
||||
```
|
||||
- **Gotchas**: the headless Service's name **must match** `spec.serviceName` on the StatefulSet — that field is what wires the StatefulSet's per-pod DNS into that specific headless Service; a mismatched name means the stable pod hostnames never resolve, even though both objects individually look fine. `type: ExternalName` Services never get a `ClusterIP` or endpoints at all — that's expected, not a misconfiguration.
|
||||
|
||||
---
|
||||
|
||||
### Q54 · [HARD] Zero-Trust Namespace
|
||||
### Q111 · [HARD] Zero-Trust Namespace
|
||||
- **Concept**: True zero-trust starts from **default-deny both directions**, then layers narrow allow rules back in — order of construction matters conceptually even though NetworkPolicies are all additive/OR'd together: without the deny-all baseline first, you're just adding allow rules on top of an implicitly-open namespace and nothing is actually restricted. Every allow-egress-to-DNS rule is non-negotiable in a default-deny-egress namespace, exactly as in Q42, or literally nothing else works either.
|
||||
- **Solution**:
|
||||
```yaml
|
||||
@ -1152,7 +1149,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q55 · [HARD] TLS Ingress with a Generated Certificate
|
||||
### Q112 · [HARD] TLS Ingress with a Generated Certificate
|
||||
- **Concept**: TLS termination at the Ingress layer needs a `kubernetes.io/tls` Secret holding `tls.crt`/`tls.key`, referenced from `spec.tls[].secretName`; for a self-signed lab cert, `openssl req -x509` generates both the key and a self-signed certificate in one step (no separate CA involved) with the `CN` acting as the hostname it's valid for.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -1175,13 +1172,13 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
paths: [{path: /, pathType: Prefix, backend: {service: {name: web, port: {number: 80}}}}]
|
||||
```
|
||||
```bash
|
||||
openssl x509 -in tls.crt -noout -fingerprint -sha256 | cut -d= -f2 > {WS}/q55/fp.txt
|
||||
openssl x509 -in tls.crt -noout -fingerprint -sha256 | cut -d= -f2 > {WS}/q112/fp.txt
|
||||
```
|
||||
- **Gotchas**: `-nodes` on `openssl req` means "no DES" — i.e. don't encrypt the private key with a passphrase, which you'd otherwise be prompted for and which `kubectl create secret tls` can't supply interactively. The fingerprint recorded in `fp.txt` must match the certificate **actually stored in the Secret**, not just the local `tls.crt` file — if you regenerate the cert after creating the Secret, they'll drift apart.
|
||||
|
||||
---
|
||||
|
||||
### Q56 · [HARD] Gateway API Canary with Header Match
|
||||
### Q113 · [HARD] Gateway API Canary with Header Match
|
||||
- **Concept**: `HTTPRoute` rules are evaluated top-to-bottom for a match; a rule with a header `matches` clause only applies to requests carrying that header, so stacking a header-matched 100%-to-`v2` rule *before* a header-less weighted-split rule implements a canary override — anyone sending the magic header always gets the canary, everyone else gets the normal weighted split. `parentRefs[].sectionName` scopes the route's attachment to one specific listener on a multi-listener Gateway, rather than every listener.
|
||||
- **Solution**:
|
||||
```yaml
|
||||
@ -1215,7 +1212,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q57 · [HARD] DNS Down: Three Faults
|
||||
### Q114 · [HARD] DNS Down: Three Faults
|
||||
- **Concept**: A single symptom ("DNS is broken") can have multiple independent causes stacked on top of each other — this task combines a bad upstream `forward` target in the Corefile, CoreDNS scaled to zero, and a stray NetworkPolicy blocking ingress to the CoreDNS pods, all at once. The exam-realistic lesson is to check **each** layer (config, replica count, network policy) rather than stopping the moment the first plausible cause is found.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -1228,8 +1225,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
# forward . 10.255.255.254 -> forward . /etc/resolv.conf
|
||||
|
||||
k -n kube-system rollout restart deploy coredns
|
||||
# FULL names: busybox's nslookup applies no search domains - `kubernetes.default` fails even when DNS works
|
||||
k run dns-check --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default.svc.cluster.local
|
||||
k run dns-check --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default
|
||||
```
|
||||
- **Gotchas**: fixing the Corefile alone looks successful (`kubectl edit` returns cleanly) but does nothing while replicas are still at 0 and/or a NetworkPolicy blocks ingress to the pods — always verify the actual symptom (`nslookup` from a real pod) rather than trusting that each individual fix "should" have worked. `rollout restart` is necessary after editing the ConfigMap because CoreDNS doesn't hot-reload `Corefile` changes on its own.
|
||||
|
||||
@ -1338,7 +1334,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q58 · [HARD] Bind the Right Local Volume
|
||||
### Q115 · [HARD] Bind the Right Local Volume
|
||||
- **Concept**: `local` PersistentVolumes are tied to a specific node via `nodeAffinity` — the scheduler must place any pod using that volume on exactly that node, which is why choosing the *wrong* one of two same-class PVs quietly schedules your pod onto the wrong hardware. A PVC's `spec.selector` (label match) is the tool for picking a specific PV among several of the same StorageClass **without editing the PVs themselves** — exactly the constraint this task imposes.
|
||||
- **Solution**:
|
||||
```yaml
|
||||
@ -1363,7 +1359,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q59 · [HARD] Migrate Data Between Claims
|
||||
### Q116 · [HARD] Migrate Data Between Claims
|
||||
- **Concept**: There's no built-in "resize/migrate a volume to a different backend" primitive — the standard pattern is a throwaway `Job` that mounts both the old and new PVCs simultaneously and copies data between their mount paths, then you cut the live workload over to the new claim. Setting the old PV's `persistentVolumeReclaimPolicy` to `Retain` *before* deleting its PVC is what keeps the original data recoverable (`Released`, not gone) in case the migration needs to be redone.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -1396,11 +1392,9 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
# re-point legacy: swap claimName old-data -> new-data, then recreate the pod
|
||||
k -n %{ns} delete pod legacy --now
|
||||
k -n %{ns} apply -f legacy.yaml
|
||||
# the Job's Completed pod still references old-data and pins it - delete that pod first
|
||||
k -n %{ns} delete pod -l job-name=migrate
|
||||
k -n %{ns} delete pvc old-data
|
||||
```
|
||||
- **Gotchas**: a pod that references a claim holds it in `pvc-protection` even after it has **Completed** — `delete pvc old-data` hangs in Terminating until the Job's pod is gone (`k describe pvc old-data` names it under "Used By"). Deleting the pod leaves the Job object and its `succeeded: 1` intact. `cp -a /src/. /dst/` (trailing `/.`) copies the **contents** of `/src` into `/dst`, preserving permissions/timestamps — dropping the trailing dot instead creates a nested `/dst/src/` directory, which silently fails any check expecting files directly under the new mount. Repointing `legacy`'s claim requires deleting and recreating the pod (`volumes[].persistentVolumeClaim.claimName` isn't mutable in place) — but the task requires the *Deployment/PVC* naming to stay the same, not the pod's UID, so this is safe here.
|
||||
- **Gotchas**: `cp -a /src/. /dst/` (trailing `/.`) copies the **contents** of `/src` into `/dst`, preserving permissions/timestamps — dropping the trailing dot instead creates a nested `/dst/src/` directory, which silently fails any check expecting files directly under the new mount. Repointing `legacy`'s claim requires deleting and recreating the pod (`volumes[].persistentVolumeClaim.claimName` isn't mutable in place) — but the task requires the *Deployment/PVC* naming to stay the same, not the pod's UID, so this is safe here.
|
||||
|
||||
---
|
||||
|
||||
@ -1490,8 +1484,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
# forward . 10.255.255.1 -> forward . /etc/resolv.conf
|
||||
|
||||
k -n kube-system rollout restart deploy coredns
|
||||
# FULL names: busybox's nslookup applies no search domains - `kubernetes.default` fails even when DNS works
|
||||
k run test-dns --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default.svc.cluster.local
|
||||
k run test-dns --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default
|
||||
```
|
||||
- **Gotchas**: `forward . /etc/resolv.conf` tells CoreDNS to use the **node's own** resolver config for anything outside the cluster zone — that's the correct, portable default; pointing `forward` at a hardcoded IP address is exactly the kind of change that breaks the moment that IP stops being reachable, which is the fault being simulated here.
|
||||
|
||||
@ -1534,7 +1527,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q60 · [HARD] Worker Node: Three Things Wrong
|
||||
### Q117 · [HARD] Worker Node: Three Things Wrong
|
||||
- **Concept**: This stacks three unrelated failure modes onto one node — a cordon (`spec.unschedulable: true`, which stops new scheduling but is unrelated to `Ready` status), a wrong `clientCAFile` path (kubelet can't start, per Q39), and the kubelet service being stopped/disabled (per Q17) — to test that you don't stop investigating after the first fix "seems to work."
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -1556,7 +1549,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q61 · [HARD] Two Control-Plane Components Down
|
||||
### Q118 · [HARD] Two Control-Plane Components Down
|
||||
- **Concept**: The scheduler-down (Q20) and controller-manager-down (Q38) failure signatures combine here — pods that do get created stay `Pending` (scheduler dead) *and* brand-new Deployments never even get a ReplicaSet (controller-manager dead), for two entirely unrelated static-pod manifest problems. Fixing only one still leaves the cluster non-functional for new workloads.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -1578,7 +1571,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q62 · [HARD] Application Down for Four Reasons
|
||||
### Q119 · [HARD] Application Down for Four Reasons
|
||||
- **Concept**: Four independent faults are stacked on one app: a `ResourceQuota` capping pods at 1 (blocking scale-up entirely), a broken image tag, a Secret missing the key the container's `env` references (`CreateContainerConfigError`), and a Service `selector`/`targetPort` mismatch (Q18's pattern again) — and critically, the fix must happen **in place**: the Deployment's UID and the Service's ClusterIP must not change, ruling out delete-and-recreate as a shortcut.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -1586,9 +1579,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
k -n %{ns} describe deploy %{dep}
|
||||
k -n %{ns} describe rs -l app=%{dep} # "exceeded quota: pods, requested: pods=1"
|
||||
|
||||
# 4, not 3: the rolling update needs a surge pod, and with maxUnavailable 0 (25% of 3 rounds
|
||||
# down) the controller will not remove an old pod first - pods=3 deadlocks the rollout
|
||||
k -n %{ns} patch quota tight -p '{"spec":{"hard":{"pods":"4"}}}'
|
||||
k -n %{ns} patch quota tight -p '{"spec":{"hard":{"pods":"3"}}}'
|
||||
k -n %{ns} set image deploy %{dep} web=nginx:1.27
|
||||
|
||||
k -n %{ns} patch secret %{dep}-env -p '{"stringData":{"DATABASE_URL":"postgres://db"}}'
|
||||
@ -1604,7 +1595,7 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q63 · [HARD] Cluster Facts, Exactly
|
||||
### Q120 · [HARD] Cluster Facts, Exactly
|
||||
- **Concept**: This is a "read the cluster's actual configuration, don't guess" exercise — pod/service CIDRs live in the static pod command-line flags of `kube-controller-manager`/`kube-apiserver` (not in any single friendly `kubectl get` field), the cluster DNS address is just the `kube-dns` Service's ClusterIP, kube-proxy's mode is buried in its ConfigMap (and an *empty* mode value means the default, `iptables` — not "not configured"), and per-node pod CIDR lives on the Node object itself.
|
||||
- **Solution**:
|
||||
```bash
|
||||
@ -1625,8 +1616,8 @@ The exam-day skill this table is really pointing at, regardless of which platfor
|
||||
|
||||
---
|
||||
|
||||
### Q66 · [HARD] Nothing Works: kube-apiserver Itself Is Down
|
||||
- **Concept**: Every other control-plane failure in this bank (scheduler, controller-manager, even etcd mid-restore) leaves `kubectl` itself usable, because kube-apiserver is still up to talk to. A broken kube-apiserver is categorically different: it's the single component every `kubectl` command depends on, so once it's down, `kubectl describe`/`logs`/`get` all stop working simultaneously and give you nothing to work with. The kubelet, however, doesn't need the API server to manage **static pods** — it reads the manifest directly off disk — so the fix is the same class of move as Q20/Q61 (repair a static pod manifest), just reached without `kubectl` in hand.
|
||||
### Q123 · [HARD] Nothing Works: kube-apiserver Itself Is Down
|
||||
- **Concept**: Every other control-plane failure in this bank (scheduler, controller-manager, even etcd mid-restore) leaves `kubectl` itself usable, because kube-apiserver is still up to talk to. A broken kube-apiserver is categorically different: it's the single component every `kubectl` command depends on, so once it's down, `kubectl describe`/`logs`/`get` all stop working simultaneously and give you nothing to work with. The kubelet, however, doesn't need the API server to manage **static pods** — it reads the manifest directly off disk — so the fix is the same class of move as Q20/Q118 (repair a static pod manifest), just reached without `kubectl` in hand.
|
||||
- **Solution**:
|
||||
```bash
|
||||
kubectl get nodes # connection refused / timeout -- confirms it's the apiserver, not your network
|
||||
|
||||
47
README.md
47
README.md
@ -4,9 +4,8 @@ Entry point is `./exam` (renamed from `cka-sim` so it can't collide with the dir
|
||||
|
||||
Hands-on exam tasks graded **per check** against a real cluster, an exam clock, a browser
|
||||
front end modelled on the real PSI exam, attempt history, and drills that rotate through the
|
||||
domains before you sit a full exam. Tracks: **CKA** (102 tasks: a random pool of 43 normal +
|
||||
24 hard, plus two fixed simulator sets, C and D, of 18 and 17 tasks), CKAD and CKS (scaffolded —
|
||||
same format, add questions when you get there).
|
||||
domains before you sit a full exam. Tracks: **CKA** (65-task bank: 43 normal + 22 hard),
|
||||
CKAD and CKS (scaffolded — same format, add questions when you get there).
|
||||
|
||||
## How a session is built
|
||||
|
||||
@ -66,10 +65,8 @@ The same state is available from the terminal if you prefer:
|
||||
|
||||
```
|
||||
./exam drill # next least-drilled domain, 5 tasks (add --hard)
|
||||
./exam drill weak | drill 2 17 20 | drill TSHOOT NET | drill setd
|
||||
./exam drill weak | drill 2 17 20 | drill TSHOOT NET
|
||||
./exam start [--hard] # full exam (gated until all domains drilled; --force)
|
||||
./exam sim c|d # a fixed 18/17-task simulator set, exam rules, 120 min
|
||||
./exam study [DOMAIN|weak|N..] # no clock, solutions visible, nothing recorded
|
||||
./exam list | show N | timer | status # during
|
||||
./exam end # submit
|
||||
./exam weak | history # afterwards
|
||||
@ -85,12 +82,10 @@ exam. Every submit appends to `history-<track>.jsonl` and regenerates `weak-spot
|
||||
latest domain scores, a "drill next" section listing the *failing checks* per task, and a
|
||||
✓ / n-of-m matrix across all attempts.
|
||||
|
||||
A task counts as weak if it failed on its latest attempt, or has failed at least twice and has
|
||||
not yet been passed twice in a row since, or was never attempted (fixed-set tasks excepted — note
|
||||
that this makes an early `drill weak` very large; name tasks explicitly for a short drill). Drills
|
||||
give 6 minutes per task and — unlike exam mode — let you check a task while you work. History
|
||||
entries marked `"synthetic": true` (scripted verification runs, not a person) are kept in the file
|
||||
but left out of every statistic.
|
||||
A task counts as weak if it failed most recently, has failed twice ever, or was never
|
||||
attempted. Drills seed only those tasks, give 6 minutes per task, and — unlike exam mode —
|
||||
let you check a task while you work. A task stops being weak only when you pass it again
|
||||
under the clock.
|
||||
|
||||
## Using your own cluster instead of kind
|
||||
|
||||
@ -108,7 +103,7 @@ set -x CKA_CP cp1; set -x CKA_W1 node1; set -x CKA_W2 node2; set -x CKA_W3 no
|
||||
`/opt/cka` and `/etc/kubernetes/manifests`, so the login must be able to. Managed clusters
|
||||
(EKS/AKS/GKE) won't work — no node or control-plane access. Startup preflights all of this.
|
||||
|
||||
The etcd tasks (2 and 45) need `etcdctl`/`etcdutl` **on the control-plane node**, as the real
|
||||
The etcd tasks (2 and 102) need `etcdctl`/`etcdutl` **on the control-plane node**, as the real
|
||||
exam has them — they cannot be copied out of the running pod, since the etcd image is distroless.
|
||||
`setup.sh` installs them on kind; on any cluster, seeding those tasks installs them if they are
|
||||
missing (from the etcd image in the node's containerd, else the upstream release, else apt) and
|
||||
@ -116,7 +111,7 @@ fails loudly if it cannot. `./etcd-tools.sh` does the same by hand.
|
||||
|
||||
## CKA bank
|
||||
|
||||
| Domain | Normal (43) | Hard (24) |
|
||||
| Domain | Normal (43) | Hard (22) |
|
||||
|---|---|---|
|
||||
| Architecture | RBAC, etcd snapshot, Kustomize, CRD, static pod, certs/CSR, Helm, aggregated ClusterRole, `kubectl explain` on a CRD, kubeadm join token, component versions | exact RBAC with an over-grant to remove, **etcd restore**, static pod on a relocated path, user → self-contained kubeconfig, Kustomize generators + patches, CRD validation/defaults, renew all control-plane certs |
|
||||
| Workloads | rolling update, sidecar, node affinity + resources, ConfigMap/Secret, metrics-server repair + HPA, PriorityClass, taints, DaemonSet, CronJob, init container, immutable ConfigMap rotation, parallel Job, quota + LimitRange | anti-affinity + toleration + priority under a quota, rollback to a specific revision then pause, native sidecar + probes + exact report, cluster audit to exact files, work in the context's namespace |
|
||||
@ -125,34 +120,20 @@ fails loudly if it cannot. `./etcd-tools.sh` does the same by hand.
|
||||
| Troubleshooting | node NotReady, broken deployment + service, CrashLoopBackOff, scheduler down, CoreDNS misconfig, controller-manager down, kubelet config drift | worker with three faults (+ uncordon check), two control-plane components down, app down for four reasons fixed in place (UID check), cluster facts exactly |
|
||||
|
||||
Disruptive tasks (anything that breaks a node or control-plane component) are seeded last and
|
||||
can block other tasks until fixed — read everything first, as in the real exam. Tasks that damage
|
||||
or depend on the same shared state declare it in `breaks` and never share a session, and the etcd
|
||||
restore (Q45) is `solo`: restoring rolls the whole cluster back, so it only runs on its own.
|
||||
Before every session a baseline pass repairs anything a previous, unfinished session left broken.
|
||||
|
||||
The kindnet CNI here **enforces** NetworkPolicy (it embeds a policy engine), so a wrong policy
|
||||
really drops traffic; no ingress or Gateway controller runs, so those are graded on the objects.
|
||||
|
||||
**Simulator sets** (`./exam sim c|d`, never drawn into random sessions): C is modelled on an
|
||||
18-question commercial mock sitting, and D is killer.sh-style — control-plane inventory, event
|
||||
forensics with crictl, a broken kubelet unit, kubelet certificates, manual scheduling with the
|
||||
scheduler stopped, Helm housekeeping with a stuck release, and an enforced AND-vs-OR
|
||||
NetworkPolicy. Both are original scenarios, not copies of any commercial simulator. (Sets A and B
|
||||
were retired in favour of these two; their last versions are in git history.)
|
||||
can block other tasks until fixed — read everything first, as in the real exam. kindnet doesn't
|
||||
enforce NetworkPolicy and no ingress/gateway controller runs, so those are graded on the objects.
|
||||
|
||||
## Adding tasks or a new track
|
||||
|
||||
Banks live in `tracks/cka/{questions,bank_extra,bank_hard,bank_sim_*}.py`; a task is:
|
||||
Banks live in `tracks/cka/{questions,bank_extra,bank_hard}.py`; a task is:
|
||||
|
||||
```python
|
||||
{"id": 103, "domain": "NET", "difficulty": "normal", # or "hard" - ids stay contiguous
|
||||
{"id": 44, "domain": "NET", "difficulty": "normal", # or "hard"
|
||||
"params": lambda r: {"ns": ns_pick(r, "svc"), "port": r.choice([8080, 9090])},
|
||||
"title": "...", "task": "...markdown; %{ns} and %{port} are substituted; `code` copies on click...",
|
||||
"setup": "bash that seeds the scenario (may use %{ns})",
|
||||
"checks": [C("what this check proves", 2, "bash; exit 0 = pass"), ...],
|
||||
"disruptive": False, # True: seeded last, after everything else is scheduled
|
||||
"breaks": ["w1-taint"], # shared state it damages or needs intact; never paired with a task sharing any
|
||||
"solo": False} # True: only ever drilled on its own
|
||||
"disruptive": False}
|
||||
```
|
||||
|
||||
Scripts get `$K` (pinned to the grading context), `$WS`, `$CP/$W1/$W2/$W3`, `nx NODE 'cmd'`,
|
||||
|
||||
241
exam
241
exam
@ -127,15 +127,6 @@ ensure_etcd_tools() {
|
||||
nx "$CP" 'command -v etcdctl >/dev/null 2>&1 && command -v etcdutl >/dev/null 2>&1' </dev/null && return 0
|
||||
bash "${CKA_ROOT:-.}/etcd-tools.sh" --emit | nx "$CP" 'sh -s'
|
||||
}
|
||||
# ensure_gateway_crds -> the Gateway API CRDs (standard channel), which kind does not ship. Q56 and
|
||||
# Q74 need them at seed time; a cluster rebuilt by ./setup.sh has none until one of them installs it.
|
||||
ensure_gateway_crds() {
|
||||
$K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 && return 0
|
||||
curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml -o /tmp/gwapi.yaml \
|
||||
&& $K apply -f /tmp/gwapi.yaml >/dev/null 2>&1 \
|
||||
&& $K wait --for=condition=Established --timeout=60s crd/gatewayclasses.gateway.networking.k8s.io \
|
||||
crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io >/dev/null 2>&1
|
||||
}
|
||||
"""
|
||||
|
||||
def _pick_track_arg(argv):
|
||||
@ -221,8 +212,8 @@ def q_by_id(n: int) -> dict[str, Any]:
|
||||
for q in QUESTIONS:
|
||||
if q["id"] == n:
|
||||
return q
|
||||
# ids run 1..N without gaps today, but print the real ranges rather than "1-<count>" so a
|
||||
# future gap can never make this message name ids that do not exist.
|
||||
# ids are not contiguous (1-43 base, 101-124 hard, 201-217 / 301-317 sim sets), so print the
|
||||
# real ranges rather than "1-<count>", which named ids that do not exist.
|
||||
ids = sorted(q["id"] for q in QUESTIONS)
|
||||
ranges, start, prev = [], ids[0], ids[0]
|
||||
for i in ids[1:] + [None]:
|
||||
@ -295,15 +286,11 @@ def score(st, results=None):
|
||||
return earned, total, (round(100 * earned / total, 1) if total else 0)
|
||||
|
||||
# ----------------------------------------------------------- history ----
|
||||
def load_history(include_synthetic=False):
|
||||
"""Attempts from history-<track>.jsonl. Entries marked "synthetic" (a scripted verification
|
||||
run, not a person sitting the tasks) stay in the file for the record but are left out of every
|
||||
statistic, weak list and "latest exam" unless explicitly asked for."""
|
||||
def load_history():
|
||||
if not os.path.exists(HISTORY_FILE):
|
||||
return []
|
||||
with open(HISTORY_FILE) as f:
|
||||
entries = [json.loads(l) for l in f if l.strip()]
|
||||
return entries if include_synthetic else [e for e in entries if not e.get("synthetic")]
|
||||
return [json.loads(l) for l in f if l.strip()]
|
||||
|
||||
def record_attempt(st):
|
||||
earned, total, pct = score(st)
|
||||
@ -326,7 +313,6 @@ class AggEntry:
|
||||
self.last_fail_at: str = ""
|
||||
self.failed: list[str] = []
|
||||
self.last_earned: str = ""
|
||||
self.streak: int = 0 # consecutive full passes at the end of the history
|
||||
|
||||
def __getitem__(self, item: str) -> Any:
|
||||
return getattr(self, item)
|
||||
@ -342,7 +328,6 @@ class AggEntry:
|
||||
"last_fail_at": self.last_fail_at,
|
||||
"failed": self.failed,
|
||||
"last_earned": self.last_earned,
|
||||
"streak": self.streak,
|
||||
}
|
||||
|
||||
|
||||
@ -356,7 +341,6 @@ def stats() -> dict[int, AggEntry]:
|
||||
a.attempts += 1
|
||||
a.last = r.get("pass")
|
||||
a.last_earned = f"{r.get('earned', 0)}/{r.get('total', 0)}"
|
||||
a.streak = a.streak + 1 if r.get("pass") else 0
|
||||
if not r.get("pass"):
|
||||
a.fails += 1
|
||||
a.last_fail_at = str(e.get("at", ""))
|
||||
@ -382,44 +366,27 @@ def drilled_domains():
|
||||
out[q["domain"]] += 1
|
||||
return out
|
||||
|
||||
def breaks_of(q):
|
||||
"""A task's "breaks" as a set. It is a string or a list: Q61 damages the scheduler AND the
|
||||
controller-manager, and declaring only one let it share a session with Q38."""
|
||||
b = q.get("breaks")
|
||||
return set() if not b else ({b} if isinstance(b, str) else set(b))
|
||||
|
||||
def no_conflicts(cands, n, taken=()):
|
||||
"""Take up to n tasks, never two that break the same thing (or clash with tasks already taken).
|
||||
A "solo" task (Q45's etcd restore rolls the WHOLE cluster back to its snapshot, wiping every
|
||||
other task's seed and answers) is only ever taken on its own."""
|
||||
used = set().union(*(breaks_of(q) for q in taken)) if taken else set()
|
||||
"""Take up to n tasks, never two that break the same thing (or clash with tasks already taken)."""
|
||||
used = {q.get("breaks") for q in taken if q.get("breaks")}
|
||||
out = []
|
||||
for q in cands:
|
||||
if q.get("solo") and (taken or out):
|
||||
continue
|
||||
if any(x.get("solo") for x in list(taken) + out):
|
||||
break
|
||||
b = breaks_of(q)
|
||||
if b & used:
|
||||
b = q.get("breaks")
|
||||
if b and b in used:
|
||||
continue
|
||||
out.append(q)
|
||||
used |= b
|
||||
if b:
|
||||
used.add(b)
|
||||
if len(out) == n:
|
||||
break
|
||||
return out
|
||||
|
||||
def split_solo(qs):
|
||||
"""Pull solo tasks out of a multi-task selection; they have to be drilled alone."""
|
||||
if len(qs) <= 1:
|
||||
return list(qs), []
|
||||
return [q for q in qs if not q.get("solo")], [q for q in qs if q.get("solo")]
|
||||
|
||||
def pick(domain, n, hard, rng, exclude=(), taken=(), unseen_only=False):
|
||||
"""n tasks from a domain: unseen first, then least recently seen; hard sessions prefer hard tasks."""
|
||||
seen = last_seen()
|
||||
|
||||
def build(allow_hard):
|
||||
pool = [q for q in POOL if q["domain"] == domain and q["id"] not in exclude and not q.get("solo")
|
||||
pool = [q for q in POOL if q["domain"] == domain and q["id"] not in exclude
|
||||
and (allow_hard or q["difficulty"] == "normal") and not (unseen_only and q["id"] in seen)]
|
||||
rng.shuffle(pool)
|
||||
pool.sort(key=lambda q: (0 if (hard and q["difficulty"] == "hard") else 1, seen.get(q["id"], -1)))
|
||||
@ -458,7 +425,7 @@ def campaign_batch(hard, rng):
|
||||
"""'drill all': DRILL_SIZE tasks you have never attempted, round-robin across domains,
|
||||
hard tasks included when --hard. When everything has been seen once, falls back to weak tasks."""
|
||||
seen = last_seen()
|
||||
pool = [q for q in POOL if q["id"] not in seen and not q.get("solo") and (hard or q["difficulty"] == "normal")]
|
||||
pool = [q for q in POOL if q["id"] not in seen and (hard or q["difficulty"] == "normal")]
|
||||
if not pool:
|
||||
return None, []
|
||||
qs, domains = [], list(DOMAINS)
|
||||
@ -508,46 +475,23 @@ def cmd_progress(args):
|
||||
print(dim(f"\n{len(QUESTIONS) - len(left)}/{len(QUESTIONS)} tasks attempted at least once." + (f" Never seen: Q{', Q'.join(map(str, left))}" if left and len(left) <= 25 else "")))
|
||||
print(dim("passed = latest attempt was a full pass. ./exam drill all [--hard] works through what's left."))
|
||||
|
||||
def is_weak(q, a):
|
||||
"""Weak = failed on the latest attempt, or failed at least twice and not yet passed twice in a
|
||||
row since - a flaky task has to prove itself - or never attempted (outside the fixed sets).
|
||||
The old rule ("failed twice, ever") kept a task weak forever: 13 of 29 listed were passing."""
|
||||
if a.attempts == 0:
|
||||
return not q.get("set")
|
||||
return a.last is False or (a.fails >= 2 and a.streak < 2)
|
||||
|
||||
def weak_ids(agg=None):
|
||||
agg = agg or stats()
|
||||
return [q["id"] for q in QUESTIONS if is_weak(q, agg[q["id"]])]
|
||||
|
||||
def weak_lines(a):
|
||||
"""What to show under a weak task: the failing checks if it failed last time, otherwise how
|
||||
close it is to dropping off the list - not the ✗ list of some older, since-fixed attempt."""
|
||||
if a.last is False:
|
||||
return [f"✗ {n}" for n in (a.failed or [])]
|
||||
return [f"passed last time ({a.last_earned}) - one more clean pass takes it off this list"]
|
||||
|
||||
def exam_domain_scores(e):
|
||||
"""domain -> (earned, total) over the tasks that were actually IN that attempt. Summing the
|
||||
weight of every task in the bank instead made a 100% run read "ARCH 38/266"."""
|
||||
out = {}
|
||||
for k, r in e.get("results", {}).items():
|
||||
q = QBYID.get(int(k))
|
||||
if q:
|
||||
de, dt = out.get(q["domain"], (0, 0))
|
||||
out[q["domain"]] = (de + r.get("earned", 0), dt + r.get("total", 0))
|
||||
return out
|
||||
return [q["id"] for q in QUESTIONS
|
||||
if (agg[q["id"]]["last"] is False or agg[q["id"]]["fails"] >= 2)
|
||||
or (agg[q["id"]]["attempts"] == 0 and not q.get("set"))]
|
||||
|
||||
def write_weak_spots():
|
||||
agg = stats(); hist = load_history()
|
||||
L = [f"# {TRACK_ID.upper()} weak spots", "", f"_Generated by exam from {len(hist)} attempt(s); regenerated on every `end`._", ""]
|
||||
exams = [e for e in hist if e["mode"] == "exam"]
|
||||
if exams:
|
||||
e = exams[-1]; ds = exam_domain_scores(e)
|
||||
e = exams[-1]
|
||||
L += [f"## Domain scores (latest full exam, {e['at']}, {e['score_pct']}%)", ""]
|
||||
for d, name in DOMAINS.items():
|
||||
if d in ds:
|
||||
L.append(f"- **{d}** {ds[d][0]}/{ds[d][1]} — {name}")
|
||||
dq = [q for q in QUESTIONS if q["domain"] == d]
|
||||
dt = sum(q["weight"] for q in dq); de = sum(e["results"].get(str(q["id"]), {}).get("earned", 0) for q in dq)
|
||||
L.append(f"- **{d}** {de}/{dt} — {name}")
|
||||
L.append("")
|
||||
L += ["## Drill next (`./exam drill weak`)", ""]
|
||||
weak = [q for q in QUESTIONS if q["id"] in weak_ids(agg) and agg[q["id"]]["attempts"] > 0]
|
||||
@ -555,8 +499,9 @@ def write_weak_spots():
|
||||
L.append("Nothing failing right now.")
|
||||
for q in weak:
|
||||
a = agg[q["id"]]
|
||||
L.append(f"### Q{q['id']:02d} · {q['domain']} · {q['title']} ({a['fails']}/{a['attempts']} failed, last {a['last_earned']} pts, last failed {a['last_fail_at']})")
|
||||
L += [f"- {n}" for n in weak_lines(a)]
|
||||
L.append(f"### Q{q['id']:02d} · {q['domain']} · {q['title']} ({a['fails']}/{a['attempts']} failed, last {a['last_earned']} pts, {a['last_fail_at']})")
|
||||
failed_list = list(a["failed"]) if isinstance(a["failed"], (list, tuple)) else []
|
||||
L += [f"- ✗ {n}" for n in failed_list]
|
||||
L.append("")
|
||||
L += ["## Attempt matrix", "", "| Q | Domain | Title | " + " | ".join(f"{e['mode'][0].upper()}{i + 1}" for i, e in enumerate(hist)) + " |",
|
||||
"|---|---|---|" + "---|" * len(hist)]
|
||||
@ -572,13 +517,13 @@ def write_weak_spots():
|
||||
|
||||
# ------------------------------------------------------------ session ----
|
||||
def preflight():
|
||||
out = subprocess.run(["kubectl", "--context", CONTEXT, "get", "nodes", "-o", "jsonpath={.items[*].metadata.name}"],
|
||||
out = subprocess.run(["kubectl", "get", "nodes", "-o", "jsonpath={.items[*].metadata.name}"],
|
||||
capture_output=True, text=True).stdout.split()
|
||||
missing = [v for v in NODES.values() if v not in out]
|
||||
if missing:
|
||||
die(f"Nodes not found in cluster: {missing}\n cluster has: {out}\n"
|
||||
" Set CKA_CP, CKA_W1, CKA_W2, CKA_W3 to your node names (needs 1 control-plane + 3 workers).")
|
||||
rc, _ = run('nx "$CP" "true" </dev/null && nx "$W1" "true" </dev/null && nx "$W2" "true" </dev/null && nx "$W3" "true" </dev/null', timeout=60)
|
||||
rc, _ = run('nx "$CP" "true" </dev/null && nx "$W2" "true" </dev/null && nx "$W3" "true" </dev/null', timeout=60)
|
||||
if rc != 0:
|
||||
die(f"Cannot run commands on nodes with CKA_NODE_ACCESS={NODE_ACCESS}.\n"
|
||||
" podman/docker -> node names must be kind container names (kind get nodes --name cka-sim)\n"
|
||||
@ -644,7 +589,7 @@ def _log_msg(*args: Any, **kwargs: Any) -> None:
|
||||
# restores every known "breaks" target to a good baseline before each session seeds, so
|
||||
# leftover damage never leaks across sessions regardless of which questions get sampled.
|
||||
RESTORE_BASELINE = r"""
|
||||
# apiserver FIRST: Q66 renames the command to "kube-apiserverr", which takes the whole API
|
||||
# apiserver FIRST: Q123 renames the command to "kube-apiserverr", which takes the whole API
|
||||
# down - every kubectl-based step below would fail until this is undone. The repair goes over
|
||||
# podman/ssh (nx), not kubectl, so it works precisely when the cluster is unreachable.
|
||||
if nx "$CP" 'grep -q "^ *- kube-apiserverr$" /etc/kubernetes/manifests/kube-apiserver.yaml' </dev/null 2>/dev/null; then
|
||||
@ -653,93 +598,48 @@ if nx "$CP" 'grep -q "^ *- kube-apiserverr$" /etc/kubernetes/manifests/kube-apis
|
||||
for _ in $(seq 1 30); do kubectl --context "${CKA_CONTEXT:-kind-cka-sim}" get --raw=/readyz >/dev/null 2>&1 && break; sleep 2; done
|
||||
fi
|
||||
|
||||
# scheduler: undo the "kube-scheduler" -> "kube-schedulerr" command typo (Q20/Q61), and put the
|
||||
# manifest back if a task had the candidate stop the scheduler by moving it out (Q97) and they
|
||||
# never moved it back - that setup keeps a pristine copy outside the static pod directory.
|
||||
# scheduler: undo the "kube-scheduler" -> "kube-schedulerr" command typo (Q20/Q118)
|
||||
nx "$CP" '
|
||||
f=/etc/kubernetes/manifests/kube-scheduler.yaml
|
||||
[ -f $f ] || { [ -f /etc/kubernetes/.cka-sim/kube-scheduler.yaml ] && cp /etc/kubernetes/.cka-sim/kube-scheduler.yaml $f; }
|
||||
sed -i "s|^\( *\)- kube-schedulerr$|\1- kube-scheduler|" $f 2>/dev/null
|
||||
' </dev/null
|
||||
|
||||
# controller-manager: undo Q38's injected flag and Q61's broken image tag. The last two lines
|
||||
# undo the retired set B's Q317 (the CSR signing controller switched off in --controllers, or in
|
||||
# its older form --cluster-signing-cert-file pointed at a missing file, which crash-looped the WHOLE
|
||||
# controller-manager); a cluster that ever ran that set may still carry either.
|
||||
# controller-manager: undo Q38's injected flag and Q118's broken image tag
|
||||
nx "$CP" '
|
||||
f=/etc/kubernetes/manifests/kube-controller-manager.yaml
|
||||
sed -i "/--leader-elect-lease-duration=1s/d" $f 2>/dev/null
|
||||
sed -i "s|\(image: registry.k8s.io/kube-controller-manager:v[0-9.]*\)-broken|\1|" $f 2>/dev/null
|
||||
sed -i "s|--cluster-signing-cert-file=/etc/kubernetes/pki/ca-signing.crt|--cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt|" $f 2>/dev/null
|
||||
sed -i "s|,-certificatesigningrequest-signing-controller||; s|,-csrsigning||" $f 2>/dev/null
|
||||
' </dev/null
|
||||
|
||||
# coredns: restore the default upstream forward (Q37/Q57 point it somewhere bogus)
|
||||
# coredns: restore the default upstream forward (Q37/Q114 point it somewhere bogus)
|
||||
cf=$($K -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' 2>/dev/null)
|
||||
if [ -n "$cf" ] && ! printf '%s' "$cf" | grep -Eq '^[[:space:]]*forward \. /etc/resolv\.conf'; then
|
||||
printf '%s' "$cf" | sed -E 's|^([[:space:]]*forward \. )[^[:space:]{]+|\1/etc/resolv.conf|' > /tmp/Corefile.restore
|
||||
$K -n kube-system create cm coredns --from-file=Corefile=/tmp/Corefile.restore --dry-run=client -o yaml | $K apply -f - >/dev/null 2>&1
|
||||
$K -n kube-system rollout restart deploy coredns >/dev/null 2>&1
|
||||
fi
|
||||
# ...and Q57's other two faults. kindnet ENFORCES NetworkPolicy, so a leftover lockdown policy on
|
||||
# the CoreDNS pods kills name resolution for the whole cluster in every later session, and so does
|
||||
# a Deployment left at 0 replicas.
|
||||
$K -n kube-system delete netpol maintenance-lockdown --ignore-not-found >/dev/null 2>&1
|
||||
r=$($K -n kube-system get deploy coredns -o jsonpath='{.spec.replicas}' 2>/dev/null)
|
||||
if [ -n "$r" ] && [ "$r" -lt 2 ]; then $K -n kube-system scale deploy coredns --replicas=2 >/dev/null 2>&1; fi
|
||||
|
||||
# w1/w2/w3 kubelet: undo Q17/Q39-style tampering, then make sure it's enabled and running.
|
||||
# staticPodPath is normalised unconditionally: Q46 relocates it to /etc/kubernetes/static, and
|
||||
# staticPodPath is normalised unconditionally: Q103 relocates it to /etc/kubernetes/static, and
|
||||
# the config.yaml.orig copy below only helps if something made that backup first - nothing does,
|
||||
# so a relocated path used to survive every later session and silently break Q05 (whose check
|
||||
# looks in /etc/kubernetes/manifests). Only restart the kubelet if we actually changed the file.
|
||||
# clientCAFile gets the same treatment: Q39 points it at ca-bundle.crt and Q60 at ca-wrong.crt, and
|
||||
# only W1 ever gets a config.yaml.orig (Q39 makes it), so an unfinished Q60 left W2's kubelet
|
||||
# crash-looping in every later session - enable/start below cannot help a kubelet that exits.
|
||||
# The systemd drop-in is checked too: Q92 points ExecStart at a kubelet binary that does not exist.
|
||||
for n in "$W1" "$W2" "$W3"; do
|
||||
nx "$n" '
|
||||
c=/var/lib/kubelet/config.yaml; d=/etc/systemd/system/kubelet.service.d/10-kubeadm.conf
|
||||
[ -f $c.orig ] && cp -f $c.orig $c
|
||||
restart=0
|
||||
if grep -q "^staticPodPath:" $c 2>/dev/null && ! grep -q "^staticPodPath: /etc/kubernetes/manifests$" $c; then
|
||||
sed -i "s|^staticPodPath: .*|staticPodPath: /etc/kubernetes/manifests|" $c; restart=1
|
||||
[ -f /var/lib/kubelet/config.yaml.orig ] && cp -f /var/lib/kubelet/config.yaml.orig /var/lib/kubelet/config.yaml
|
||||
if grep -q "^staticPodPath:" /var/lib/kubelet/config.yaml 2>/dev/null \
|
||||
&& ! grep -q "^staticPodPath: /etc/kubernetes/manifests$" /var/lib/kubelet/config.yaml; then
|
||||
sed -i "s|^staticPodPath: .*|staticPodPath: /etc/kubernetes/manifests|" /var/lib/kubelet/config.yaml
|
||||
systemctl restart kubelet >/dev/null 2>&1
|
||||
fi
|
||||
ca=$(awk "/clientCAFile:/{print \$2}" $c 2>/dev/null)
|
||||
if [ -n "$ca" ] && [ ! -f "$ca" ]; then
|
||||
sed -i "s|clientCAFile: .*|clientCAFile: /etc/kubernetes/pki/ca.crt|" $c; restart=1
|
||||
fi
|
||||
bin=$(sed -n "s|^ExecStart=\([^ ]*kubelet\) .*|\1|p" $d 2>/dev/null | tail -1)
|
||||
if [ -n "$bin" ] && [ ! -x "$bin" ]; then
|
||||
sed -i "s|^ExecStart=$bin |ExecStart=/usr/bin/kubelet |" $d; systemctl daemon-reload; restart=1
|
||||
fi
|
||||
[ $restart = 1 ] && systemctl restart kubelet >/dev/null 2>&1
|
||||
systemctl enable kubelet >/dev/null 2>&1
|
||||
systemctl start kubelet >/dev/null 2>&1
|
||||
' </dev/null
|
||||
done
|
||||
|
||||
# static pods the bank drops on nodes: Q05's static-web on W3, Q46's relocated copies, Q94's
|
||||
# ops-beacon on the control plane - and edge-cache, left on W3 by the retired set B (it sat in
|
||||
# ContainerCreating for days, its hostPath deliberately missing). Each setup recreates what its
|
||||
# own session needs.
|
||||
nx "$W3" 'rm -f /etc/kubernetes/manifests/static-web*.yaml /etc/kubernetes/manifests/edge-cache.yaml /etc/kubernetes/static/*.yaml' </dev/null
|
||||
nx "$CP" 'rm -f /etc/kubernetes/manifests/ops-beacon*.yaml' </dev/null
|
||||
|
||||
# default StorageClass: Q16 makes fast-local the default and strips `standard`. Leave exactly one
|
||||
# default - `standard` - so "the default class" means the same thing in every session.
|
||||
if $K get sc standard >/dev/null 2>&1; then
|
||||
for sc in $($K get sc -o jsonpath='{.items[*].metadata.name}'); do
|
||||
[ "$sc" = standard ] && continue
|
||||
[ "$($K get sc "$sc" -o jsonpath='{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" = true ] \
|
||||
&& $K annotate sc "$sc" storageclass.kubernetes.io/is-default-class- >/dev/null 2>&1
|
||||
done
|
||||
$K annotate sc standard storageclass.kubernetes.io/is-default-class=true --overwrite >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# w1-taint: clear every randomized demo taint key the bank can apply. Q25 draws from
|
||||
# dedicated/team/workload and Q50 from pool/lane; `maintenance` was the retired set B's and may
|
||||
# still be on a cluster that ran it. Miss one and that node silently stops accepting pods.
|
||||
# dedicated/team/workload, Q107 from pool/lane, and Q307 pins maintenance=inprogress - miss one
|
||||
# and that node silently stops accepting pods for every later session.
|
||||
# One key per call: `kubectl taint node N a- b-` aborts on the first key that is not present and
|
||||
# removes NOTHING, so the single taint that is actually set survives the "cleanup" and W1 keeps
|
||||
# rejecting pods in every later session - the exact failure this block exists to prevent.
|
||||
@ -759,18 +659,23 @@ if $K -n kube-system get deploy metrics-server >/dev/null 2>&1 \
|
||||
-p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--kubelet-insecure-tls"}]' >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# cordons: Q60 cordons W2, and a drain task left half-done leaves a node cordoned. A cordon left
|
||||
# behind fails any later task that needs every worker (Q26's DaemonSet coverage, Q50's spread).
|
||||
# w1-block/w2-drain/w3-drain: Q307 cordons W1 and Q117 cordons W2, and Q208/Q309 have the
|
||||
# candidate drain W2/W3. A cordon left behind fails any later task that counts pods per node
|
||||
# (Q26/Q211 DaemonSet coverage, Q212's one-pod-per-node spread).
|
||||
for n in "$W1" "$W2" "$W3"; do $K uncordon "$n" >/dev/null 2>&1 || true; done
|
||||
|
||||
# Deliberately NOT restored, and why:
|
||||
# etcd (Q45) and certs (Q65). Neither setup damages anything - Q45 only takes a snapshot
|
||||
# and deletes a ConfigMap, Q65 only records the current notBefore. Any damage comes from a
|
||||
# etcd (Q102) and certs (Q122). Neither setup damages anything - Q102 only takes a snapshot
|
||||
# and deletes a ConfigMap, Q122 only records the current notBefore. Any damage comes from a
|
||||
# half-finished candidate attempt (etcd.yaml left pointing at a restored data-dir, static pod
|
||||
# manifests moved to /tmp and never moved back), which is not deterministic: there is no way
|
||||
# to tell "mid-attempt" from "correctly completed" without guessing, and guessing wrong here
|
||||
# rewrites etcd's data-dir or re-issues certs on a healthy cluster. Re-run ./setup.sh (or
|
||||
# ./teardown.sh && ./setup.sh) if one of those leaves the control plane wedged.
|
||||
#
|
||||
# proxy-rules (Q302) needs nothing: its setup only creates a namespace and deletes a pod and
|
||||
# service. The "breaks" key is there for no_conflicts() bookkeeping, not because it damages
|
||||
# shared state.
|
||||
"""
|
||||
|
||||
def restore_baseline(log: Callable[..., Any] = _log_msg):
|
||||
@ -889,7 +794,7 @@ def cmd_drill(args):
|
||||
label, qs = campaign_batch(hard, random.Random())
|
||||
if not qs:
|
||||
print(bold("Every task in the bank has been attempted." ) + " Switching to weak tasks.")
|
||||
qs, _ = split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])
|
||||
qs = [q for q in QUESTIONS if q["id"] in weak_ids()]
|
||||
qs = no_conflicts(qs, DRILL_SIZE)
|
||||
if not qs:
|
||||
die("Nothing weak either - you have passed everything on its latest attempt. Time for ./exam start --hard.")
|
||||
@ -908,16 +813,10 @@ def cmd_drill(args):
|
||||
ids += [q["id"] for q in set_questions(a.lower().replace("set", ""))]
|
||||
elif a.upper() in DOMAINS:
|
||||
ids += [q["id"] for q in QUESTIONS if q["domain"] == a.upper()]
|
||||
elif a.isdigit():
|
||||
ids.append(q_by_id(int(a))["id"])
|
||||
else:
|
||||
die(f"'{a}' is not a task id, a domain ({', '.join(DOMAINS)}) or a set "
|
||||
f"({', '.join('set' + k for k in SETS) or 'none'}).")
|
||||
ids.append(q_by_id(int(a))["id"])
|
||||
ids = sorted(set(ids))
|
||||
qs, solo = split_solo([q for q in QUESTIONS if q["id"] in ids])
|
||||
if solo:
|
||||
print(yellow(f"Dropped Q{', Q'.join(str(q['id']) for q in solo)}: restoring it rolls the whole cluster back and would "
|
||||
f"wipe the other tasks. Drill it on its own: ./exam drill {solo[0]['id']}"))
|
||||
qs = [q for q in QUESTIONS if q["id"] in ids]
|
||||
kept = no_conflicts(qs, len(qs))
|
||||
if len(kept) < len(qs):
|
||||
dropped = [q["id"] for q in qs if q not in kept]
|
||||
@ -941,10 +840,10 @@ def cmd_study(args):
|
||||
qs = pick(domain, n, hard, rng)
|
||||
print(bold(f"Study: {DOMAINS[domain]}"))
|
||||
elif rest and rest[0] == "weak":
|
||||
qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])[0], n)
|
||||
qs = no_conflicts([q for q in QUESTIONS if q["id"] in weak_ids()], n)
|
||||
print(bold("Study: tasks you have been failing"))
|
||||
elif rest:
|
||||
qs = no_conflicts(split_solo([q_by_id(int(a)) for a in rest])[0], len(rest))
|
||||
qs = no_conflicts([q_by_id(int(a)) for a in rest], len(rest))
|
||||
print(bold("Study: " + ", ".join(f"Q{q['id']}" for q in qs)))
|
||||
else:
|
||||
domain, qs = next_drill(hard, rng)
|
||||
@ -952,7 +851,7 @@ def cmd_study(args):
|
||||
begin_session(qs, "drill", EXAM_SECONDS, force, hard=hard, untimed_session=True)
|
||||
|
||||
def cmd_sim(args):
|
||||
"""A fixed simulator set, full exam rules and clock."""
|
||||
"""Fixed 17-task simulator set, full exam rules and clock."""
|
||||
force = "--force" in args
|
||||
names = [a.lower() for a in args if not a.startswith("--")]
|
||||
if not names or names[0] not in SETS:
|
||||
@ -996,13 +895,8 @@ def cmd_show(args):
|
||||
|
||||
def cmd_grade(args):
|
||||
st = require_started()
|
||||
force = "--force" in args; args = [a for a in args if a != "--force"]
|
||||
if not args:
|
||||
die("usage: ./exam grade N | all")
|
||||
# The web UI already refused this; the CLI handed out per-check feedback mid-exam, which a sim
|
||||
# meant to rehearse the real thing (no feedback until you submit) must not do by default.
|
||||
if st.get("mode") == "exam" and not st.get("ended_at") and not force:
|
||||
die("No feedback during an exam - the real one gives none. Submit with ./exam end (--force to peek anyway).")
|
||||
ids = [q["id"] for q in session_questions(st)] if args[0] == "all" else [int(a) for a in args]
|
||||
for n in ids:
|
||||
q = q_by_id(n); r = grade_one(q)
|
||||
@ -1086,7 +980,7 @@ def cmd_end(args):
|
||||
print_score(st)
|
||||
if entry is None:
|
||||
print(dim("Study session - not added to history or weak-spots.")); return
|
||||
print(dim(f"Time used: {fmt(st['ended_at'] - st['started_at'] - paused_total(st))}" + (" (over time)" if st.get("over_time") else "")))
|
||||
print(dim(f"Time used: {fmt(st['ended_at'] - st['started_at'])}" + (" (over time)" if st.get("over_time") else "")))
|
||||
failed = sorted(int(k) for k, v in entry["results"].items() if not v["pass"])
|
||||
print(dim(f"Attempt #{len(load_history())} logged to {os.path.basename(HISTORY_FILE)}; {os.path.basename(WEAK_FILE)} updated."))
|
||||
if failed:
|
||||
@ -1118,34 +1012,31 @@ def cmd_weak(args):
|
||||
print(green(" none - everything attempted has passed on its latest try"))
|
||||
for i in ids:
|
||||
q = q_by_id(i); a = agg[i]
|
||||
print(f"\n{red('Q%02d' % i)} {q['domain']:<7} {q['title']} " + dim(f"{a['fails']}/{a['attempts']} failed, last {a['last_earned']} pts, last failed {a['last_fail_at']}"))
|
||||
for n in weak_lines(a):
|
||||
print(dim(" " + n))
|
||||
print(f"\n{red('Q%02d' % i)} {q['domain']:<7} {q['title']} " + dim(f"{a['fails']}/{a['attempts']} failed, last {a['last_earned']} pts, {a['last_fail_at']}"))
|
||||
for n in a["failed"]:
|
||||
print(dim(" ✗ " + n))
|
||||
never = [q["id"] for q in QUESTIONS if agg[q["id"]]["attempts"] == 0]
|
||||
if never:
|
||||
print(dim(f"\nNever attempted: Q{', Q'.join(map(str, never))}"))
|
||||
print(dim(f"\nFull log: {WEAK_FILE} Drill them: ./exam drill weak"))
|
||||
|
||||
def cmd_history(args):
|
||||
hist = load_history(include_synthetic=True)
|
||||
hist = load_history()
|
||||
if not hist:
|
||||
die("No history yet.")
|
||||
print(bold(f"{'#':>3} {'when':<17}{'mode':<7}{'score':>7} {'time':<9} tasks"))
|
||||
for i, e in enumerate(hist, 1):
|
||||
col = green if e["score_pct"] >= PASS_PCT else red
|
||||
tasks = f"all {len(QUESTIONS)}" if len(e["ids"]) == len(QUESTIONS) else "Q" + ",".join(map(str, e["ids"]))
|
||||
tag = dim(" [scripted verification - not counted]") if e.get("synthetic") else ""
|
||||
print(f"{i:>3} {e['at']:<17}{(e['mode'] + ('*' if e.get('hard') else '')):<7}{col('%5.1f%%' % e['score_pct']):>16} {fmt(e['time_used']):<9} {tasks}{tag}")
|
||||
exams = [e for e in hist if e["mode"] == "exam" and not e.get("synthetic")]
|
||||
print(f"{i:>3} {e['at']:<17}{(e['mode'] + ('*' if e.get('hard') else '')):<7}{col('%5.1f%%' % e['score_pct']):>16} {fmt(e['time_used']):<9} {tasks}")
|
||||
exams = [e for e in hist if e["mode"] == "exam"]
|
||||
print(dim("* = hard"))
|
||||
if len(exams) >= 2:
|
||||
print(bold("\nDomain trend (full exams, % of that exam's points in the domain):"))
|
||||
print(bold("\nDomain trend (full exams, % of points):"))
|
||||
for d in DOMAINS:
|
||||
cells = []
|
||||
for e in exams:
|
||||
de, dt = exam_domain_scores(e).get(d, (0, 0))
|
||||
cells.append(f"{round(100 * de / dt):>3}%" if dt else " -")
|
||||
print(f" {d:<7} " + " → ".join(cells))
|
||||
dq = [q for q in QUESTIONS if q["domain"] == d]; dt = sum(q["weight"] for q in dq)
|
||||
pts = [round(100 * sum(e["results"].get(str(q["id"]), {}).get("earned", 0) for q in dq) / dt) for e in exams]
|
||||
print(f" {d:<7} " + " → ".join(f"{p:>3}%" for p in pts))
|
||||
|
||||
def cmd_reset(args):
|
||||
if not args:
|
||||
@ -1223,12 +1114,12 @@ class Api(http.server.BaseHTTPRequestHandler):
|
||||
elif body.get("campaign"):
|
||||
_, qs = campaign_batch(hard, rng)
|
||||
if not qs:
|
||||
qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in weak_ids()])[0], DRILL_SIZE)
|
||||
qs = no_conflicts([q for q in QUESTIONS if q["id"] in weak_ids()], DRILL_SIZE)
|
||||
elif body.get("next"):
|
||||
_, qs = next_drill(hard, rng)
|
||||
else:
|
||||
ids = body.get("ids") or weak_ids()
|
||||
qs = no_conflicts(split_solo([q for q in QUESTIONS if q["id"] in ids])[0], len(ids))
|
||||
qs = no_conflicts([q for q in QUESTIONS if q["id"] in ids], len(ids))
|
||||
per = DRILL_SECONDS_PER_TASK_HARD if hard else DRILL_SECONDS_PER_TASK
|
||||
begin_session(qs, "drill", per * len(qs), True, log=ulog, hard=hard,
|
||||
untimed_session=bool(body.get("study")))
|
||||
@ -1378,9 +1269,9 @@ def cmd_help(args):
|
||||
exact-output tasks, compound failures, collateral checks and exam-style contexts.
|
||||
list tasks in this session, with current + historical results
|
||||
show N display task N
|
||||
grade N|all grade one task (or all) per check - drills only (--force to peek in an exam)
|
||||
grade N|all grade one task (or all) per check - drills only, in the UI
|
||||
status / timer time remaining (timer = live countdown)
|
||||
pause / resume stop the clock (not recorded anywhere - but the real exam has no pause)
|
||||
pause / resume stop the clock (recorded on the attempt - the real exam has no pause)
|
||||
end submit: grade per check, score, log attempt, update weak-spots
|
||||
weak what you keep getting wrong, with the failing checks
|
||||
history every attempt, scores, and per-domain trend
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
{"at": "2026-09-06T22:07", "mode": "drill", "hard": false, "ids": [1, 5, 6, 34, 41], "score_pct": 8.0, "earned": 2, "total": 25, "time_used": 432, "results": {"1": {"pass": false, "earned": 2, "total": 6, "failed": ["ServiceAccount rbac-app/deploy-bot exists", "Role deploy-manager and RoleBinding deploy-bot-binding exist", "deploy-bot can get/list/watch/create/update/patch/delete deployments"]}, "5": {"pass": false, "earned": 0, "total": 5, "failed": ["manifest containing static-web is in /etc/kubernetes/manifests on the node", "mirror pod static-web-<node> is Running", "image is nginx:1.27"]}, "6": {"pass": false, "earned": 0, "total": 6, "failed": ["apiserver-expiry.txt records the real expiry month and year", "CSR jane exists with signer kubernetes.io/kube-apiserver-client", "CSR jane approved and certificate issued", "jane can list pods in default (view) but not delete them"]}, "34": {"pass": false, "earned": 0, "total": 4, "failed": ["path.txt has the correct field path", "Gadget/sample created with the field = 4"]}, "41": {"pass": false, "earned": 0, "total": 4, "failed": ["apiserver and kubelet versions correct", "etcd and runtime versions correct"]}}}
|
||||
{"at": "2026-09-08T19:59", "mode": "exam", "hard": false, "ids": [2, 3, 4, 8, 12, 13, 15, 16, 17, 18, 19, 20, 25, 28, 29, 30, 33, 37, 40, 42], "score_pct": 14.3, "earned": 15, "total": 105, "time_used": 2902, "results": {"2": {"pass": true, "earned": 7, "total": 7, "failed": []}, "3": {"pass": false, "earned": 0, "total": 5, "failed": ["overlay kustomization.yaml exists", "resources deployed into namespace store-prod", "store-api has 3 replicas", "Deployment and Service carry label env=prod"]}, "4": {"pass": true, "earned": 4, "total": 4, "failed": []}, "8": {"pass": false, "earned": 0, "total": 4, "failed": ["pod log-shipper Running with containers app and shipper", "volume logs is an emptyDir mounted at /var/log in both containers", "shipper's logs show lines written by app"]}, "12": {"pass": false, "earned": 0, "total": 4, "failed": ["Service shop-svc is NodePort, port 80 -> targetPort 80", "nodePort is 30080", "Service has endpoints"]}, "13": {"pass": false, "earned": 0, "total": 5, "failed": ["ingressClassName nginx and host shop.example.com", "/api (Prefix) -> api:8080", "/ (Prefix) -> web:80"]}, "15": {"pass": false, "earned": 0, "total": 6, "failed": ["PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual", "PVC pvc-data Bound to pv-data", "data-pod Running with claim mounted at /usr/share/data"]}, "16": {"pass": false, "earned": 0, "total": 5, "failed": ["StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", "fast-local is default and standard is not", "fast-claim picked up fast-local and is Bound", "fast-pod Running with claim at /data"]}, "17": {"pass": false, "earned": 0, "total": 8, "failed": ["node is Ready", "kubelet is active", "kubelet is enabled (survives reboot)"]}, "18": {"pass": false, "earned": 0, "total": 7, "failed": ["all payments replicas available", "Service payments has endpoints (selector fixed)", "Service targetPort matches container port 80"]}, "19": {"pass": false, "earned": 1, "total": 6, "failed": ["crasher.log captured and contains the DB_HOST failure", "pod crasher Running with DB_HOST set"]}, "20": {"pass": false, "earned": 0, "total": 8, "failed": ["kube-scheduler static pod manifest repaired", "new pods get scheduled"]}, "25": {"pass": false, "earned": 0, "total": 4, "failed": ["cka-sim-worker carries workload=batch:NoSchedule", "deployment tolerates the taint", "both pods Running on cka-sim-worker"]}, "28": {"pass": false, "earned": 0, "total": 4, "failed": ["pod Running, init container named setup completed", "emptyDir 'content' shared by both containers", "nginx serves the init-written content"]}, "29": {"pass": false, "earned": 1, "total": 4, "failed": ["deployment now references a different ConfigMap", "all pods available and see mode=new"]}, "30": {"pass": false, "earned": 0, "total": 4, "failed": ["container port 80 named app-port", "Service port 8080 -> targetPort app-port (by name)", "readiness probe uses the named port; endpoints ready"]}, "33": {"pass": false, "earned": 1, "total": 5, "failed": ["ops-44 has an aggregationRule with the right label", "aggregation populated rules from the two labelled roles", "lee can list pods and services cluster-wide"]}, "37": {"pass": false, "earned": 1, "total": 6, "failed": ["CoreDNS Corefile forwards to the node resolver again", "in-cluster and external names resolve from a pod"]}, "40": {"pass": false, "earned": 0, "total": 5, "failed": ["join.sh contains a kubeadm join with token and CA hash", "the token in the file exists in the cluster with TTL ~2h"]}, "42": {"pass": false, "earned": 0, "total": 4, "failed": ["applies to app=client-47, Egress only", "DNS rule: kube-system namespace + k8s-app=kube-dns, ports 53 UDP and TCP", "TCP 443 to 192.168.100.0/24 only"]}}}
|
||||
{"at": "2026-09-10T21:07", "mode": "drill", "hard": true, "ids": [49, 55, 58, 63, 64], "score_pct": 6.5, "earned": 2, "total": 31, "time_used": 30, "results": {"49": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "55": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret portal.lab.local-tls is kubernetes.io/tls with CN portal.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "58": {"pass": false, "earned": 1, "total": 6, "failed": ["PVC uses a selector and is Bound to lv-nvme", "consumer Running on the nvme node with /data mounted"]}, "63": {"pass": false, "earned": 0, "total": 5, "failed": ["pod-cidr and service-cidr", "dns-ip and proxy-mode", "cka-sim-worker3 pod CIDR"]}, "64": {"pass": false, "earned": 1, "total": 6, "failed": ["Deployment ledger-14 in ac-omega (the context's namespace), 2 available, owner=ac", "Service ledger-14 port 9000 in the same namespace", "ns.txt has the namespace"]}}}
|
||||
{"at": "2026-09-10T21:07", "mode": "drill", "hard": true, "ids": [106, 112, 115, 120, 121], "score_pct": 6.5, "earned": 2, "total": 31, "time_used": 30, "results": {"106": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "112": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret portal.lab.local-tls is kubernetes.io/tls with CN portal.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "115": {"pass": false, "earned": 1, "total": 6, "failed": ["PVC uses a selector and is Bound to lv-nvme", "consumer Running on the nvme node with /data mounted"]}, "120": {"pass": false, "earned": 0, "total": 5, "failed": ["pod-cidr and service-cidr", "dns-ip and proxy-mode", "cka-sim-worker3 pod CIDR"]}, "121": {"pass": false, "earned": 1, "total": 6, "failed": ["Deployment ledger-14 in ac-omega (the context's namespace), 2 available, owner=ac", "Service ledger-14 port 9000 in the same namespace", "ns.txt has the namespace"]}}}
|
||||
{"at": "2026-09-12T22:17", "mode": "drill", "hard": false, "ids": [1, 6, 21, 34, 41], "score_pct": 87.5, "earned": 21, "total": 24, "time_used": 534, "results": {"1": {"pass": true, "earned": 6, "total": 6, "failed": []}, "6": {"pass": false, "earned": 3, "total": 6, "failed": ["CSR jane exists with signer kubernetes.io/kube-apiserver-client", "CSR jane approved and certificate issued"]}, "21": {"pass": true, "earned": 4, "total": 4, "failed": []}, "34": {"pass": true, "earned": 4, "total": 4, "failed": []}, "41": {"pass": true, "earned": 4, "total": 4, "failed": []}}}
|
||||
{"at": "2026-09-12T22:59", "mode": "drill", "hard": false, "ids": [17, 19, 37, 38, 39], "score_pct": 29.4, "earned": 10, "total": 34, "time_used": 1987, "results": {"17": {"pass": false, "earned": 4, "total": 8, "failed": ["kubelet is active", "kubelet is enabled (survives reboot)"]}, "19": {"pass": false, "earned": 1, "total": 6, "failed": ["crasher.log captured and contains the DB_HOST failure", "pod crasher Running with DB_HOST set"]}, "37": {"pass": false, "earned": 1, "total": 6, "failed": ["CoreDNS Corefile forwards to the node resolver again", "in-cluster and external names resolve from a pod"]}, "38": {"pass": false, "earned": 0, "total": 7, "failed": ["kube-controller-manager manifest repaired and pod Running", "a new Deployment gets a ReplicaSet and running pods"]}, "39": {"pass": false, "earned": 4, "total": 7, "failed": ["kubelet config points at a CA file that exists", "kubelet active and enabled"]}}}
|
||||
{"at": "2026-09-13T01:06", "mode": "drill", "hard": false, "ids": [17, 19, 37, 38, 39], "score_pct": 29.4, "earned": 10, "total": 34, "time_used": 6649, "results": {"17": {"pass": false, "earned": 4, "total": 8, "failed": ["kubelet is active", "kubelet is enabled (survives reboot)"]}, "19": {"pass": false, "earned": 1, "total": 6, "failed": ["crasher.log captured and contains the DB_HOST failure", "pod crasher Running with DB_HOST set"]}, "37": {"pass": false, "earned": 1, "total": 6, "failed": ["CoreDNS Corefile forwards to the node resolver again", "in-cluster and external names resolve from a pod"]}, "38": {"pass": false, "earned": 0, "total": 7, "failed": ["kube-controller-manager manifest repaired and pod Running", "a new Deployment gets a ReplicaSet and running pods"]}, "39": {"pass": false, "earned": 4, "total": 7, "failed": ["kubelet config points at a CA file that exists", "kubelet active and enabled"]}}}
|
||||
{"at": "2026-09-14T16:49", "mode": "drill", "hard": false, "ids": [3, 5, 6, 8, 12, 13, 15, 16, 17, 18, 19, 20, 25, 28, 29, 30, 33, 37, 38, 39, 40, 42, 49, 55, 58, 63, 64], "score_pct": 42.0, "earned": 63, "total": 150, "time_used": 510, "results": {"17": {"pass": true, "earned": 8, "total": 8, "failed": []}, "20": {"pass": true, "earned": 8, "total": 8, "failed": []}, "3": {"pass": true, "earned": 5, "total": 5, "failed": []}, "5": {"pass": true, "earned": 5, "total": 5, "failed": []}, "8": {"pass": true, "earned": 4, "total": 4, "failed": []}, "12": {"pass": true, "earned": 4, "total": 4, "failed": []}, "38": {"pass": true, "earned": 7, "total": 7, "failed": []}, "18": {"pass": true, "earned": 7, "total": 7, "failed": []}, "19": {"pass": false, "earned": 4, "total": 6, "failed": ["crasher.log captured and contains the DB_HOST failure"]}, "6": {"pass": true, "earned": 6, "total": 6, "failed": []}, "13": {"pass": false, "earned": 0, "total": 5, "failed": ["ingressClassName nginx and host shop.example.com", "/api (Prefix) -> api:8080", "/ (Prefix) -> web:80"]}, "15": {"pass": false, "earned": 0, "total": 6, "failed": ["PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual", "PVC pvc-data Bound to pv-data", "data-pod Running with claim mounted at /usr/share/data"]}, "16": {"pass": false, "earned": 0, "total": 5, "failed": ["StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", "fast-local is default and standard is not", "fast-claim picked up fast-local and is Bound", "fast-pod Running with claim at /data"]}, "25": {"pass": false, "earned": 0, "total": 4, "failed": ["cka-sim-worker carries workload=batch:NoSchedule", "deployment tolerates the taint", "both pods Running on cka-sim-worker"]}, "28": {"pass": false, "earned": 0, "total": 4, "failed": ["pod Running, init container named setup completed", "emptyDir 'content' shared by both containers", "nginx serves the init-written content"]}, "29": {"pass": false, "earned": 1, "total": 4, "failed": ["deployment now references a different ConfigMap", "all pods available and see mode=new"]}, "30": {"pass": false, "earned": 0, "total": 4, "failed": ["container port 80 named svc-http", "Service port 8080 -> targetPort svc-http (by name)", "readiness probe uses the named port; endpoints ready"]}, "33": {"pass": false, "earned": 1, "total": 5, "failed": ["ops-19 has an aggregationRule with the right label", "aggregation populated rules from the two labelled roles", "sam can list pods and services cluster-wide"]}, "37": {"pass": false, "earned": 1, "total": 6, "failed": ["CoreDNS Corefile forwards to the node resolver again", "in-cluster and external names resolve from a pod"]}, "39": {"pass": false, "earned": 0, "total": 7, "failed": ["kubelet config points at a CA file that exists", "node Ready", "kubelet active and enabled"]}, "40": {"pass": false, "earned": 0, "total": 5, "failed": ["join.sh contains a kubeadm join with token and CA hash", "the token in the file exists in the cluster with TTL ~2h"]}, "42": {"pass": false, "earned": 0, "total": 4, "failed": ["applies to app=client-64, Egress only", "DNS rule: kube-system namespace + k8s-app=kube-dns, ports 53 UDP and TCP", "TCP 443 to 172.16.0.0/16 only"]}, "49": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "55": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret admin.lab.local-tls is kubernetes.io/tls with CN admin.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "58": {"pass": false, "earned": 1, "total": 6, "failed": ["PVC uses a selector and is Bound to lv-nvme", "consumer Running on the nvme node with /data mounted"]}, "63": {"pass": false, "earned": 0, "total": 5, "failed": ["pod-cidr and service-cidr", "dns-ip and proxy-mode", "cka-sim-worker3 pod CIDR"]}, "64": {"pass": false, "earned": 1, "total": 6, "failed": ["Deployment ledger-73 in ac-bravo (the context's namespace), 2 available, owner=ac", "Service ledger-73 port 9000 in the same namespace", "ns.txt has the namespace"]}}}
|
||||
{"at": "2026-09-15T20:55", "mode": "drill", "hard": false, "ids": [13, 15, 16, 19, 25, 28, 29, 30, 33, 37, 39, 40, 42, 49, 55, 58, 63, 64], "score_pct": 41.7, "earned": 40, "total": 96, "time_used": 608, "results": {"13": {"pass": true, "earned": 5, "total": 5, "failed": []}, "19": {"pass": true, "earned": 6, "total": 6, "failed": []}, "33": {"pass": true, "earned": 5, "total": 5, "failed": []}, "30": {"pass": true, "earned": 4, "total": 4, "failed": []}, "37": {"pass": true, "earned": 6, "total": 6, "failed": []}, "39": {"pass": true, "earned": 7, "total": 7, "failed": []}, "40": {"pass": false, "earned": 0, "total": 5, "failed": ["join.sh contains a kubeadm join with token and CA hash", "the token in the file exists in the cluster with TTL ~6h"]}, "42": {"pass": true, "earned": 4, "total": 4, "failed": []}, "15": {"pass": false, "earned": 0, "total": 6, "failed": ["PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual", "PVC pvc-data Bound to pv-data", "data-pod Running with claim mounted at /usr/share/data"]}, "16": {"pass": false, "earned": 0, "total": 5, "failed": ["StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", "fast-local is default and standard is not", "fast-claim picked up fast-local and is Bound", "fast-pod Running with claim at /data"]}, "25": {"pass": false, "earned": 0, "total": 4, "failed": ["cka-sim-worker carries team=finance:NoSchedule", "deployment tolerates the taint", "both pods Running on cka-sim-worker"]}, "28": {"pass": false, "earned": 0, "total": 4, "failed": ["pod Running, init container named setup completed", "emptyDir 'content' shared by both containers", "nginx serves the init-written content"]}, "29": {"pass": false, "earned": 1, "total": 4, "failed": ["deployment now references a different ConfigMap", "all pods available and see mode=new"]}, "49": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "55": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret portal.lab.local-tls is kubernetes.io/tls with CN portal.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "58": {"pass": false, "earned": 1, "total": 6, "failed": ["PVC uses a selector and is Bound to lv-nvme", "consumer Running on the nvme node with /data mounted"]}, "63": {"pass": false, "earned": 0, "total": 5, "failed": ["pod-cidr and service-cidr", "dns-ip and proxy-mode", "cka-sim-worker3 pod CIDR"]}, "64": {"pass": false, "earned": 1, "total": 6, "failed": ["Deployment ledger-21 in ac-echo (the context's namespace), 2 available, owner=ac", "Service ledger-21 port 9000 in the same namespace", "ns.txt has the namespace"]}}}
|
||||
{"at": "2026-09-14T16:49", "mode": "drill", "hard": false, "ids": [3, 5, 6, 8, 12, 13, 15, 16, 17, 18, 19, 20, 25, 28, 29, 30, 33, 37, 38, 39, 40, 42, 106, 112, 115, 120, 121], "score_pct": 42.0, "earned": 63, "total": 150, "time_used": 510, "results": {"17": {"pass": true, "earned": 8, "total": 8, "failed": []}, "20": {"pass": true, "earned": 8, "total": 8, "failed": []}, "3": {"pass": true, "earned": 5, "total": 5, "failed": []}, "5": {"pass": true, "earned": 5, "total": 5, "failed": []}, "8": {"pass": true, "earned": 4, "total": 4, "failed": []}, "12": {"pass": true, "earned": 4, "total": 4, "failed": []}, "38": {"pass": true, "earned": 7, "total": 7, "failed": []}, "18": {"pass": true, "earned": 7, "total": 7, "failed": []}, "19": {"pass": false, "earned": 4, "total": 6, "failed": ["crasher.log captured and contains the DB_HOST failure"]}, "6": {"pass": true, "earned": 6, "total": 6, "failed": []}, "13": {"pass": false, "earned": 0, "total": 5, "failed": ["ingressClassName nginx and host shop.example.com", "/api (Prefix) -> api:8080", "/ (Prefix) -> web:80"]}, "15": {"pass": false, "earned": 0, "total": 6, "failed": ["PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual", "PVC pvc-data Bound to pv-data", "data-pod Running with claim mounted at /usr/share/data"]}, "16": {"pass": false, "earned": 0, "total": 5, "failed": ["StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", "fast-local is default and standard is not", "fast-claim picked up fast-local and is Bound", "fast-pod Running with claim at /data"]}, "25": {"pass": false, "earned": 0, "total": 4, "failed": ["cka-sim-worker carries workload=batch:NoSchedule", "deployment tolerates the taint", "both pods Running on cka-sim-worker"]}, "28": {"pass": false, "earned": 0, "total": 4, "failed": ["pod Running, init container named setup completed", "emptyDir 'content' shared by both containers", "nginx serves the init-written content"]}, "29": {"pass": false, "earned": 1, "total": 4, "failed": ["deployment now references a different ConfigMap", "all pods available and see mode=new"]}, "30": {"pass": false, "earned": 0, "total": 4, "failed": ["container port 80 named svc-http", "Service port 8080 -> targetPort svc-http (by name)", "readiness probe uses the named port; endpoints ready"]}, "33": {"pass": false, "earned": 1, "total": 5, "failed": ["ops-19 has an aggregationRule with the right label", "aggregation populated rules from the two labelled roles", "sam can list pods and services cluster-wide"]}, "37": {"pass": false, "earned": 1, "total": 6, "failed": ["CoreDNS Corefile forwards to the node resolver again", "in-cluster and external names resolve from a pod"]}, "39": {"pass": false, "earned": 0, "total": 7, "failed": ["kubelet config points at a CA file that exists", "node Ready", "kubelet active and enabled"]}, "40": {"pass": false, "earned": 0, "total": 5, "failed": ["join.sh contains a kubeadm join with token and CA hash", "the token in the file exists in the cluster with TTL ~2h"]}, "42": {"pass": false, "earned": 0, "total": 4, "failed": ["applies to app=client-64, Egress only", "DNS rule: kube-system namespace + k8s-app=kube-dns, ports 53 UDP and TCP", "TCP 443 to 172.16.0.0/16 only"]}, "106": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "112": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret admin.lab.local-tls is kubernetes.io/tls with CN admin.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "115": {"pass": false, "earned": 1, "total": 6, "failed": ["PVC uses a selector and is Bound to lv-nvme", "consumer Running on the nvme node with /data mounted"]}, "120": {"pass": false, "earned": 0, "total": 5, "failed": ["pod-cidr and service-cidr", "dns-ip and proxy-mode", "cka-sim-worker3 pod CIDR"]}, "121": {"pass": false, "earned": 1, "total": 6, "failed": ["Deployment ledger-73 in ac-bravo (the context's namespace), 2 available, owner=ac", "Service ledger-73 port 9000 in the same namespace", "ns.txt has the namespace"]}}}
|
||||
{"at": "2026-09-15T20:55", "mode": "drill", "hard": false, "ids": [13, 15, 16, 19, 25, 28, 29, 30, 33, 37, 39, 40, 42, 106, 112, 115, 120, 121], "score_pct": 41.7, "earned": 40, "total": 96, "time_used": 608, "results": {"13": {"pass": true, "earned": 5, "total": 5, "failed": []}, "19": {"pass": true, "earned": 6, "total": 6, "failed": []}, "33": {"pass": true, "earned": 5, "total": 5, "failed": []}, "30": {"pass": true, "earned": 4, "total": 4, "failed": []}, "37": {"pass": true, "earned": 6, "total": 6, "failed": []}, "39": {"pass": true, "earned": 7, "total": 7, "failed": []}, "40": {"pass": false, "earned": 0, "total": 5, "failed": ["join.sh contains a kubeadm join with token and CA hash", "the token in the file exists in the cluster with TTL ~6h"]}, "42": {"pass": true, "earned": 4, "total": 4, "failed": []}, "15": {"pass": false, "earned": 0, "total": 6, "failed": ["PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual", "PVC pvc-data Bound to pv-data", "data-pod Running with claim mounted at /usr/share/data"]}, "16": {"pass": false, "earned": 0, "total": 5, "failed": ["StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", "fast-local is default and standard is not", "fast-claim picked up fast-local and is Bound", "fast-pod Running with claim at /data"]}, "25": {"pass": false, "earned": 0, "total": 4, "failed": ["cka-sim-worker carries team=finance:NoSchedule", "deployment tolerates the taint", "both pods Running on cka-sim-worker"]}, "28": {"pass": false, "earned": 0, "total": 4, "failed": ["pod Running, init container named setup completed", "emptyDir 'content' shared by both containers", "nginx serves the init-written content"]}, "29": {"pass": false, "earned": 1, "total": 4, "failed": ["deployment now references a different ConfigMap", "all pods available and see mode=new"]}, "106": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "112": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret portal.lab.local-tls is kubernetes.io/tls with CN portal.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "115": {"pass": false, "earned": 1, "total": 6, "failed": ["PVC uses a selector and is Bound to lv-nvme", "consumer Running on the nvme node with /data mounted"]}, "120": {"pass": false, "earned": 0, "total": 5, "failed": ["pod-cidr and service-cidr", "dns-ip and proxy-mode", "cka-sim-worker3 pod CIDR"]}, "121": {"pass": false, "earned": 1, "total": 6, "failed": ["Deployment ledger-21 in ac-echo (the context's namespace), 2 available, owner=ac", "Service ledger-21 port 9000 in the same namespace", "ns.txt has the namespace"]}}}
|
||||
{"at": "2026-09-16T19:40", "mode": "drill", "hard": false, "ids": [15, 16, 25, 28, 29, 40], "score_pct": 50.0, "earned": 14, "total": 28, "time_used": 1258, "results": {"15": {"pass": true, "earned": 6, "total": 6, "failed": []}, "25": {"pass": true, "earned": 4, "total": 4, "failed": []}, "29": {"pass": true, "earned": 4, "total": 4, "failed": []}, "16": {"pass": false, "earned": 0, "total": 5, "failed": ["StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", "fast-local is default and standard is not", "fast-claim picked up fast-local and is Bound", "fast-pod Running with claim at /data"]}, "28": {"pass": false, "earned": 0, "total": 4, "failed": ["pod Running, init container named setup completed", "emptyDir 'content' shared by both containers", "nginx serves the init-written content"]}, "40": {"pass": false, "earned": 0, "total": 5, "failed": ["join.sh contains a kubeadm join with token and CA hash", "the token in the file exists in the cluster with TTL ~24h"]}}}
|
||||
{"at": "2026-09-19T12:27", "mode": "drill", "hard": false, "ids": [49, 217, 34, 202, 45, 301, 39, 13, 32, 204, 25, 2, 8, 55, 4, 52, 313, 9], "score_pct": 39.7, "earned": 48, "total": 121, "time_used": 4197, "results": {"8": {"pass": true, "earned": 4, "total": 4, "failed": []}, "34": {"pass": true, "earned": 4, "total": 4, "failed": []}, "39": {"pass": true, "earned": 7, "total": 7, "failed": []}, "13": {"pass": true, "earned": 5, "total": 5, "failed": []}, "25": {"pass": true, "earned": 4, "total": 4, "failed": []}, "301": {"pass": true, "earned": 9, "total": 9, "failed": []}, "4": {"pass": true, "earned": 4, "total": 4, "failed": []}, "9": {"pass": true, "earned": 4, "total": 4, "failed": []}, "49": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "217": {"pass": false, "earned": 0, "total": 10, "failed": ["Pod tiger-probe Running with both labels", "container.txt holds a real container ID for that Pod", "container.txt records the runtimeType", "container.log holds the httpd startup output"]}, "202": {"pass": false, "earned": 3, "total": 8, "failed": ["watchAllNamespaces=true reached the pod env", "Ledger main-ledger has currency USD and retentionDays 30"]}, "45": {"pass": false, "earned": 2, "total": 10, "failed": ["ConfigMap restore-proof-67 exists again with its original data", "etcd uses a restored data directory (manifest changed)"]}, "32": {"pass": false, "earned": 0, "total": 4, "failed": ["ResourceQuota limits pods/6, requests.cpu 1, limits.memory 2Gi", "LimitRange defaults set", "worker pods Running with defaulted requests"]}, "204": {"pass": false, "earned": 0, "total": 8, "failed": ["evicted-first.txt lists exactly the BestEffort pods", "api-qos.txt says Burstable", "guaranteed.txt names orion-ledger"]}, "2": {"pass": false, "earned": 0, "total": 7, "failed": ["/opt/cka/etcd-snapshot.db exists on the control-plane node", "snapshot is a real etcd backup (> 100 KB)"]}, "55": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret secure.lab.local-tls is kubernetes.io/tls with CN secure.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "52": {"pass": false, "earned": 2, "total": 8, "failed": ["native sidecar: initContainer agent with restartPolicy Always", "probes on app as specified", "Running, shared log written by agent, grace period 5"]}, "313": {"pass": false, "earned": 0, "total": 11, "failed": ["Secret classic-tls is a TLS secret with CN classic.r700.io", "both host rules route to the right Services on /", "TLS covers both hosts with classic-tls", "defaultBackend points at web-classic:80, class is nginx"]}}}
|
||||
{"at": "2026-09-20T17:32", "mode": "exam", "hard": true, "ids": [68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85], "score_pct": 100.0, "earned": 163, "total": 163, "time_used": 2909, "results": {"69": {"pass": true, "earned": 9, "total": 9, "failed": []}, "71": {"pass": true, "earned": 9, "total": 9, "failed": []}, "72": {"pass": true, "earned": 11, "total": 11, "failed": []}, "68": {"pass": true, "earned": 8, "total": 8, "failed": []}, "70": {"pass": true, "earned": 9, "total": 9, "failed": []}, "73": {"pass": true, "earned": 7, "total": 7, "failed": []}, "74": {"pass": true, "earned": 10, "total": 10, "failed": []}, "75": {"pass": true, "earned": 9, "total": 9, "failed": []}, "76": {"pass": true, "earned": 9, "total": 9, "failed": []}, "77": {"pass": true, "earned": 8, "total": 8, "failed": []}, "78": {"pass": true, "earned": 7, "total": 7, "failed": []}, "79": {"pass": true, "earned": 11, "total": 11, "failed": []}, "80": {"pass": true, "earned": 10, "total": 10, "failed": []}, "81": {"pass": true, "earned": 9, "total": 9, "failed": []}, "82": {"pass": true, "earned": 9, "total": 9, "failed": []}, "83": {"pass": true, "earned": 10, "total": 10, "failed": []}, "84": {"pass": true, "earned": 10, "total": 10, "failed": []}, "85": {"pass": true, "earned": 8, "total": 8, "failed": []}}, "synthetic": true, "note": "Scripted end-to-end verification of Set C (tasks solved programmatically), not a person sitting it"}
|
||||
{"at": "2026-09-19T12:27", "mode": "drill", "hard": false, "ids": [106, 217, 34, 202, 102, 301, 39, 13, 32, 204, 25, 2, 8, 112, 4, 109, 313, 9], "score_pct": 39.7, "earned": 48, "total": 121, "time_used": 4197, "results": {"8": {"pass": true, "earned": 4, "total": 4, "failed": []}, "34": {"pass": true, "earned": 4, "total": 4, "failed": []}, "39": {"pass": true, "earned": 7, "total": 7, "failed": []}, "13": {"pass": true, "earned": 5, "total": 5, "failed": []}, "25": {"pass": true, "earned": 4, "total": 4, "failed": []}, "301": {"pass": true, "earned": 9, "total": 9, "failed": []}, "4": {"pass": true, "earned": 4, "total": 4, "failed": []}, "9": {"pass": true, "earned": 4, "total": 4, "failed": []}, "106": {"pass": false, "earned": 0, "total": 7, "failed": ["CRD exists, Namespaced, replicas has min/max and is required", "good created with environment prod", "replicas 0 is rejected and the message was saved", "defaulting works: defaulted.spec.environment == dev"]}, "217": {"pass": false, "earned": 0, "total": 10, "failed": ["Pod tiger-probe Running with both labels", "container.txt holds a real container ID for that Pod", "container.txt records the runtimeType", "container.log holds the httpd startup output"]}, "202": {"pass": false, "earned": 3, "total": 8, "failed": ["watchAllNamespaces=true reached the pod env", "Ledger main-ledger has currency USD and retentionDays 30"]}, "102": {"pass": false, "earned": 2, "total": 10, "failed": ["ConfigMap restore-proof-67 exists again with its original data", "etcd uses a restored data directory (manifest changed)"]}, "32": {"pass": false, "earned": 0, "total": 4, "failed": ["ResourceQuota limits pods/6, requests.cpu 1, limits.memory 2Gi", "LimitRange defaults set", "worker pods Running with defaulted requests"]}, "204": {"pass": false, "earned": 0, "total": 8, "failed": ["evicted-first.txt lists exactly the BestEffort pods", "api-qos.txt says Burstable", "guaranteed.txt names orion-ledger"]}, "2": {"pass": false, "earned": 0, "total": 7, "failed": ["/opt/cka/etcd-snapshot.db exists on the control-plane node", "snapshot is a real etcd backup (> 100 KB)"]}, "112": {"pass": false, "earned": 0, "total": 7, "failed": ["Secret secure.lab.local-tls is kubernetes.io/tls with CN secure.lab.local", "Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", "fp.txt matches the secret's certificate fingerprint"]}, "109": {"pass": false, "earned": 2, "total": 8, "failed": ["native sidecar: initContainer agent with restartPolicy Always", "probes on app as specified", "Running, shared log written by agent, grace period 5"]}, "313": {"pass": false, "earned": 0, "total": 11, "failed": ["Secret classic-tls is a TLS secret with CN classic.r700.io", "both host rules route to the right Services on /", "TLS covers both hosts with classic-tls", "defaultBackend points at web-classic:80, class is nginx"]}}}
|
||||
{"at": "2026-09-20T17:32", "mode": "exam", "hard": true, "ids": [401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418], "score_pct": 100.0, "earned": 163, "total": 163, "time_used": 2909, "results": {"402": {"pass": true, "earned": 9, "total": 9, "failed": []}, "404": {"pass": true, "earned": 9, "total": 9, "failed": []}, "405": {"pass": true, "earned": 11, "total": 11, "failed": []}, "401": {"pass": true, "earned": 8, "total": 8, "failed": []}, "403": {"pass": true, "earned": 9, "total": 9, "failed": []}, "406": {"pass": true, "earned": 7, "total": 7, "failed": []}, "407": {"pass": true, "earned": 10, "total": 10, "failed": []}, "408": {"pass": true, "earned": 9, "total": 9, "failed": []}, "409": {"pass": true, "earned": 9, "total": 9, "failed": []}, "410": {"pass": true, "earned": 8, "total": 8, "failed": []}, "411": {"pass": true, "earned": 7, "total": 7, "failed": []}, "412": {"pass": true, "earned": 11, "total": 11, "failed": []}, "413": {"pass": true, "earned": 10, "total": 10, "failed": []}, "414": {"pass": true, "earned": 9, "total": 9, "failed": []}, "415": {"pass": true, "earned": 9, "total": 9, "failed": []}, "416": {"pass": true, "earned": 10, "total": 10, "failed": []}, "417": {"pass": true, "earned": 10, "total": 10, "failed": []}, "418": {"pass": true, "earned": 8, "total": 8, "failed": []}}}
|
||||
|
||||
4
setup.sh
4
setup.sh
@ -88,7 +88,7 @@ fi
|
||||
# copied out of the running etcd pod (that image is distroless), so install them properly.
|
||||
echo "==> Installing etcdctl/etcdutl on the control-plane node..."
|
||||
printf ' '
|
||||
CONTAINER_RUNTIME="$RT" bash ./etcd-tools.sh || echo " warn: no etcd client on the control-plane node; Q02/Q45 will say so when they seed"
|
||||
CONTAINER_RUNTIME="$RT" bash ./etcd-tools.sh || echo " warn: no etcd client on the control-plane node; Q02/Q102 will say so when they seed"
|
||||
|
||||
# remember the runtime so the CLI uses the matching exec command
|
||||
mkdir -p .state && echo "$RT" > .state/runtime
|
||||
@ -98,4 +98,4 @@ echo
|
||||
echo "Cluster ready. Nodes:"
|
||||
kubectl get nodes -o wide
|
||||
echo
|
||||
echo "Next: ./exam web (browser UI) or ./exam drill (5 tasks from your least-drilled domain)"
|
||||
echo "Next: ./exam start (seeds all 20 tasks and starts the 120-minute clock)"
|
||||
|
||||
@ -169,9 +169,8 @@ for t in $($K -n kube-system get secrets -o jsonpath='{.items[?(@.type=="bootstr
|
||||
C("the token in the file exists in the cluster with TTL ~%{ttl}", 3, r"""
|
||||
tok=$(grep -o -- '--token [a-z0-9]\{6\}\.[a-z0-9]\{16\}' "$WS/q40/join.sh" 2>/dev/null | awk '{print $2}'); [ -n "$tok" ] || exit 1
|
||||
id=${tok%%.*}; exp=$(j -n kube-system secret bootstrap-token-$id '{.data.expiration}' | base64 -d 2>/dev/null); [ -n "$exp" ] || exit 1
|
||||
ct=$(j -n kube-system secret bootstrap-token-$id '{.metadata.creationTimestamp}'); [ -n "$ct" ] || exit 1
|
||||
secs=$(( $(date -d "$exp" +%s) - $(date -d "$ct" +%s) )); want=$(( $(echo %{ttl} | tr -d h) * 3600 ))
|
||||
[ $secs -ge $((want - 120)) ] && [ $secs -le $((want + 120)) ] && [ "$(date -d "$exp" +%s)" -gt "$(date +%s)" ]"""),
|
||||
secs=$(( $(date -d "$exp" +%s) - $(date +%s) )); want=$(( $(echo %{ttl} | tr -d h) * 3600 ))
|
||||
[ $secs -gt $((want - 900)) ] && [ $secs -le $((want + 60)) ]"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -198,9 +197,9 @@ kub=$(j node %{node} '{.status.nodeInfo.kubeletVersion}')
|
||||
grep -qx "apiserver=$api" "$f" && grep -qx "kubelet=$kub" "$f" """),
|
||||
C("etcd and runtime versions correct", 2, r"""
|
||||
f="$WS/q41/versions.txt"
|
||||
etcd=$(j -n kube-system pod -l component=etcd '{.items[0].spec.containers[0].image}' | sed 's/.*://'); ev=${etcd%-*}
|
||||
etcd=$(j -n kube-system pod -l component=etcd '{.items[0].spec.containers[0].image}' | sed 's/.*://')
|
||||
rt=$(j node %{node} '{.status.nodeInfo.containerRuntimeVersion}')
|
||||
grep -qxE "etcd=v?($etcd|$ev)" "$f" && grep -qx "runtime=$rt" "$f" """),
|
||||
grep -qx "etcd=$etcd" "$f" && grep -qx "runtime=$rt" "$f" """),
|
||||
],
|
||||
},
|
||||
# ---------------------------------------------------------------- WORK ----
|
||||
@ -245,17 +244,11 @@ c["args"]=[a for a in c["args"] if a!="--kubelet-insecure-tls"]; print(json.dump
|
||||
"setup": NS + r"""
|
||||
mkns %{ns}
|
||||
$K delete priorityclass %{pc} --ignore-not-found >/dev/null
|
||||
$K -n %{ns} delete deploy payments --ignore-not-found --wait=true >/dev/null 2>&1
|
||||
$K -n %{ns} create deploy payments --image=nginx:1.25 --replicas=2 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
""",
|
||||
"checks": [
|
||||
C("PriorityClass %{pc} value %{val}, not default", 2, r"""eq "$(j priorityclass %{pc} '{.value}')" %{val} && [ "$(j priorityclass %{pc} '{.globalDefault}')" != true ]"""),
|
||||
C("payments pods running with priority %{val}", 2, r"""
|
||||
for t in $(seq 1 15); do # the old ReplicaSet's pods linger for a moment while they terminate
|
||||
ok=1; eq "$(j -n %{ns} deploy payments '{.status.availableReplicas}')" 2 || ok=0
|
||||
for p in $(j -n %{ns} pod -l app=payments '{.items[*].spec.priority}'); do eq "$p" %{val} || ok=0; done
|
||||
[ $ok = 1 ] && exit 0; sleep 2
|
||||
done; exit 1"""),
|
||||
C("payments pods running with priority %{val}", 2, r"""eq "$(j -n %{ns} deploy payments '{.status.availableReplicas}')" 2 && for p in $(j -n %{ns} pod -l app=payments '{.items[*].spec.priority}'); do eq "$p" %{val} || exit 1; done"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -282,8 +275,6 @@ $K -n %{ns} delete deploy %{dep} --ignore-not-found >/dev/null
|
||||
},
|
||||
{
|
||||
"id": 26, "domain": "WORK", "title": "DaemonSet on every node",
|
||||
# needs every node schedulable: Q25/Q50 taint W1 and must keep the taint
|
||||
"breaks": ["w1-taint"],
|
||||
"params": lambda r: {"ns": ns_pick(r, "agents"), "ds": name_pick(r, "node-agent")},
|
||||
"task": """
|
||||
Create a DaemonSet `%{ds}` in namespace `%{ns}` (image `busybox:1.36`, command `sleep 3600`)
|
||||
@ -293,9 +284,7 @@ Requests: cpu 10m, memory 16Mi.
|
||||
"setup": NS + r"""mkns %{ns}; $K -n %{ns} delete ds %{ds} --ignore-not-found >/dev/null""",
|
||||
"checks": [
|
||||
C("DaemonSet exists with requests set", 1, r"""eq "$(j -n %{ns} ds %{ds} '{.spec.template.spec.containers[0].resources.requests.cpu}')" 10m"""),
|
||||
C("tolerates the control-plane taint", 1, r"""$K -n %{ns} get ds %{ds} -o json 2>/dev/null | python3 -c '
|
||||
import sys,json; t=json.load(sys.stdin)["spec"]["template"]["spec"].get("tolerations") or []
|
||||
sys.exit(0 if any("control-plane" in (x.get("key") or "") or (x.get("operator")=="Exists" and not x.get("key")) for x in t) else 1)'"""),
|
||||
C("tolerates the control-plane taint", 1, r"""j -n %{ns} ds %{ds} '{.spec.template.spec.tolerations[*].key}' | grep -q control-plane"""),
|
||||
C("one ready pod per node (including control-plane)", 2, r"""n=$($K get nodes --no-headers 2>/dev/null | wc -l); eq "$(j -n %{ns} ds %{ds} '{.status.numberReady}')" "$n" && eq "$(j -n %{ns} ds %{ds} '{.status.desiredNumberScheduled}')" "$n" """),
|
||||
],
|
||||
},
|
||||
@ -429,8 +418,7 @@ $K -n %{ns} create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o y
|
||||
$K -n %{ns} create deploy api --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n %{ns} expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n %{ns} expose deploy api --port=8080 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
# these fail harmlessly when the Gateway API CRDs are not installed yet - installing them is step 1
|
||||
$K delete gatewayclass %{gc} --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete gateway %{gw} --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete httproute %{host}-route --ignore-not-found >/dev/null 2>&1 || true
|
||||
$K delete gatewayclass %{gc} --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete gateway %{gw} --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete httproute %{host}-route --ignore-not-found >/dev/null 2>&1
|
||||
""",
|
||||
"checks": [
|
||||
C("Gateway API CRDs installed (gateways, httproutes)", 1, r"""$K get crd gateways.gateway.networking.k8s.io httproutes.gateway.networking.k8s.io >/dev/null 2>&1"""),
|
||||
@ -442,7 +430,7 @@ eq "$($h -o jsonpath='{.spec.parentRefs[0].name}')" %{gw} || exit 1
|
||||
$h -o json | python3 -c '
|
||||
import sys,json; r=json.load(sys.stdin)["spec"]["rules"]
|
||||
api=[x for x in r if any(m.get("path",{}).get("value")=="/api" for m in x.get("matches",[]))]
|
||||
web=[x for x in r if x not in api and x.get("backendRefs",[{}])[0].get("name")=="web"]
|
||||
web=[x for x in r if x is not api and x.get("backendRefs",[{}])[0].get("name")=="web"]
|
||||
ok=api and api[0]["backendRefs"][0]["name"]=="api" and api[0]["backendRefs"][0]["port"]==8080 and web and web[0]["backendRefs"][0]["port"]==80
|
||||
sys.exit(0 if ok else 1)'"""),
|
||||
],
|
||||
@ -504,10 +492,8 @@ sys.exit(0 if ok else 1)'"""),
|
||||
1. In `%{ns}`, StatefulSet `%{sts}` (2 replicas) exists but has no Service. Create the **headless**
|
||||
Service `%{sts}` it needs (port 5432) so that `%{sts}-0.%{sts}.%{ns}.svc.cluster.local` resolves.
|
||||
2. Create Service `legacy` of type ExternalName pointing at `%{ext}`.
|
||||
3. From a pod in the namespace, resolve pod 0's stable name
|
||||
`%{sts}-0.%{sts}.%{ns}.svc.cluster.local` and write the full nslookup output to
|
||||
`{WS}/q43/pod0.txt`. (busybox's nslookup applies no search domains, so a short name such as
|
||||
`%{sts}-0.%{sts}` fails even when DNS is healthy.)
|
||||
3. From a pod in the namespace, resolve `%{sts}-0.%{sts}` and write the full nslookup output
|
||||
to `{WS}/q43/pod0.txt`.
|
||||
""",
|
||||
"setup": NS + r"""
|
||||
mkns %{ns}; rm -rf "$WS/q43"; mkdir -p "$WS/q43"
|
||||
@ -564,18 +550,11 @@ spec: {accessModes: [ReadWriteOnce], storageClassName: manual, resources: {reque
|
||||
EOF
|
||||
for i in $(seq 1 20); do [ "$(j -n %{ns} pvc old-claim '{.status.phase}')" = Bound ] && break; sleep 1; done
|
||||
$K -n %{ns} delete pvc old-claim >/dev/null
|
||||
# every PV that exists now; the "no extra PV" check compares against this, so Retain leftovers from
|
||||
# earlier sessions (this task's own, Q15's pv-data, Set C's) can no longer fail it
|
||||
$K get pv -o jsonpath='{.items[*].metadata.name}' > "$WS/.q35-pvs"
|
||||
""",
|
||||
"checks": [
|
||||
C("PV %{pv} still exists and was not recreated", 2, r"""[ -n "$(j pv %{pv} '{.metadata.uid}')" ] && eq "$(j pv %{pv} '{.spec.persistentVolumeReclaimPolicy}')" Retain"""),
|
||||
C("PVC %{pvc} Bound to %{pv}", 3, r"""eq "$(j -n %{ns} pvc %{pvc} '{.status.phase}')" Bound && eq "$(j -n %{ns} pvc %{pvc} '{.spec.volumeName}')" %{pv}"""),
|
||||
C("no extra PVs were created", 1, r"""
|
||||
before=" $(cat "$WS/.q35-pvs" 2>/dev/null) "; [ "$before" != " " ] || exit 1
|
||||
for pv in $($K get pv -o jsonpath='{range .items[?(@.spec.storageClassName=="manual")]}{.metadata.name}{" "}{end}' 2>/dev/null); do
|
||||
case "$before" in *" $pv "*) ;; *) exit 1 ;; esac
|
||||
done"""),
|
||||
C("no extra PVs were created", 1, r"""[ "$($K get pv --no-headers 2>/dev/null | grep -c manual)" -le 2 ]"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Hard-difficulty CKA tasks (ids 44-67): multi-part, no hints, traps, exact output,
|
||||
"""Hard-difficulty CKA tasks (ids 101-122): multi-part, no hints, traps, exact output,
|
||||
compound failures, collateral-damage checks, exam-style contexts. Modelled on the kind of
|
||||
difficulty people report from killer.sh, not on its content."""
|
||||
from tracks.cka.common import C, NS, ns_pick, name_pick
|
||||
@ -11,7 +11,7 @@ def ctx_pick(r):
|
||||
QUESTIONS = [
|
||||
# ================================================================ ARCH ====
|
||||
{
|
||||
"id": 44, "domain": "ARCH", "difficulty": "hard", "title": "Exact RBAC with an existing over-grant",
|
||||
"id": 101, "domain": "ARCH", "difficulty": "hard", "title": "Exact RBAC with an existing over-grant",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "ci"), "sa": name_pick(r, "ci-bot")},
|
||||
"task": CTX + """
|
||||
ServiceAccount `%{sa}` in namespace `%{ns}` is used by a CI system. Its permissions must be
|
||||
@ -32,18 +32,14 @@ $K delete clusterrole %{sa}-nodes --ignore-not-found >/dev/null; $K delete clust
|
||||
$K create clusterrolebinding %{sa}-legacy-admin --clusterrole=cluster-admin --serviceaccount=%{ns}:%{sa} >/dev/null
|
||||
""",
|
||||
"checks": [
|
||||
C("can get/list/watch/patch deployments and replicasets in %{ns}", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 || exit 1 # not via cluster-admin
|
||||
for res in deployments.apps replicasets.apps; do for v in get list watch patch; do eq "$($K auth can-i $v $res -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes || exit 1; done; done"""),
|
||||
C("can create pods/exec in %{ns}; can list nodes", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 || exit 1
|
||||
eq "$($K auth can-i create pods --subresource=exec -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes && eq "$($K auth can-i list nodes --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes"""),
|
||||
C("can get/list/watch/patch deployments and replicasets in %{ns}", 2, r"""for res in deployments.apps replicasets.apps; do for v in get list watch patch; do eq "$($K auth can-i $v $res -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes || exit 1; done; done"""),
|
||||
C("can create pods/exec in %{ns}; can list nodes", 2, r"""eq "$($K auth can-i create pods/exec -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes && eq "$($K auth can-i list nodes --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null)" yes"""),
|
||||
C("no over-grant: cannot delete deployments, cannot get secrets, cannot get nodes", 3, r"""for t in "delete deployments -n %{ns}" "get secrets -n %{ns}" "get nodes" "create pods -n default"; do eq "$($K auth can-i $t --as=system:serviceaccount:%{ns}:%{sa} 2>/dev/null || true)" no || exit 1; done"""),
|
||||
C("the cluster-admin binding is gone and named objects exist", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 && $K -n %{ns} get role/%{sa}-role rolebinding/%{sa}-rb >/dev/null 2>&1 && $K get clusterrole/%{sa}-nodes clusterrolebinding/%{sa}-crb >/dev/null 2>&1"""),
|
||||
C("the cluster-admin binding is gone and named objects exist", 2, r"""! $K get clusterrolebinding %{sa}-legacy-admin >/dev/null 2>&1 && $K -n %{ns} get role %{sa}-role rolebinding %{sa}-rb >/dev/null 2>&1 && $K get clusterrole %{sa}-nodes clusterrolebinding %{sa}-crb >/dev/null 2>&1"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 45, "domain": "ARCH", "breaks": "etcd", "difficulty": "hard", "title": "etcd restore",
|
||||
# solo: restoring rolls EVERY API object back to the snapshot - other tasks' seeds and answers too
|
||||
"solo": True,
|
||||
"id": 102, "domain": "ARCH", "breaks": "etcd", "difficulty": "hard", "title": "etcd restore",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H", "cm": name_pick(r, "restore-proof")},
|
||||
"task": CTX + """
|
||||
An etcd snapshot taken earlier is at `/opt/cka/backup.db` on the control-plane node.
|
||||
@ -53,9 +49,6 @@ been deleted.
|
||||
**Restore the cluster's etcd from that snapshot** so the ConfigMap exists again. The cluster
|
||||
must be healthy afterwards (API server up, all kube-system pods Running).
|
||||
Do not recreate the ConfigMap by hand — its data must come from the snapshot.
|
||||
|
||||
A restore rolls every API object back to the moment of the snapshot — on a real exam cluster that
|
||||
would include the work you did on other tasks, so a restore is the last thing you do.
|
||||
""",
|
||||
"setup": r"""
|
||||
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/backup.db'
|
||||
@ -65,29 +58,15 @@ $K -n default create cm %{cm} --from-literal=proof=$(date +%s)-%{cm} >/dev/null
|
||||
out=$(nx "$CP" 'etcdctl --endpoints=https://127.0.0.1:2379 --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key snapshot save /opt/cka/backup.db' 2>&1) \
|
||||
|| { echo "snapshot save on $CP failed: $out"; exit 1; }
|
||||
sleep 2; $K -n default delete cm %{cm} >/dev/null
|
||||
nx "$CP" 'awk "/hostPath:/{getline; p=\$2} /name: etcd-data/{if (p) print p}" /etc/kubernetes/manifests/etcd.yaml' </dev/null > "$WS/.q45-datadir"
|
||||
date +%s > "$WS/.q45-t0"
|
||||
""",
|
||||
"checks": [
|
||||
C("ConfigMap %{cm} exists again with its original data", 6, r"""j -n default cm %{cm} '{.data.proof}' | grep -q -- '-%{cm}$'"""),
|
||||
C("etcd runs on a data directory restored from the snapshot", 2, r"""
|
||||
was=$(cat "$WS/.q45-datadir" 2>/dev/null); t0=$(cat "$WS/.q45-t0" 2>/dev/null); [ -n "$was" ] && [ -n "$t0" ] || exit 1
|
||||
now=$(nx "$CP" 'awk "/hostPath:/{getline; p=\$2} /name: etcd-data/{if (p) print p}" /etc/kubernetes/manifests/etcd.yaml' </dev/null)
|
||||
[ -n "$now" ] || exit 1
|
||||
[ "$now" != "$was" ] && exit 0
|
||||
# the same directory name as before is fine if it was rebuilt from the snapshot after seeding
|
||||
m=$(nx "$CP" "stat -c %Y $now/member 2>/dev/null" </dev/null); [ -n "$m" ] && [ "$m" -gt "$t0" ]"""),
|
||||
C("control plane healthy: etcd, apiserver, scheduler, controller-manager Running", 2, r"""j -n default cm %{cm} '{.data.proof}' | grep -q -- '-%{cm}$' || exit 1 # only once restored
|
||||
# right after a restore the API holds the snapshot's mirror pods and the kubelet replaces them, so a
|
||||
# grade in the first minute can find one missing - retry instead of failing on that window
|
||||
for i in $(seq 1 20); do
|
||||
ok=1; for c in etcd kube-apiserver kube-scheduler kube-controller-manager; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || ok=0; done
|
||||
[ $ok = 1 ] && exit 0; sleep 3
|
||||
done; exit 1"""),
|
||||
C("etcd uses a restored data directory (manifest changed)", 2, r"""! nx "$CP" 'grep -q "path: /var/lib/etcd$" /etc/kubernetes/manifests/etcd.yaml' """),
|
||||
C("control plane healthy: etcd, apiserver, scheduler, controller-manager Running", 2, r"""for c in etcd kube-apiserver kube-scheduler kube-controller-manager; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || exit 1; done"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 46, "domain": "ARCH", "breaks": ["w3-kubelet", "w3-staticpath"], "difficulty": "hard", "title": "Static pod on a relocated manifest path",
|
||||
"id": 103, "domain": "ARCH", "breaks": "w3-kubelet", "difficulty": "hard", "title": "Static pod on a relocated manifest path",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H", "ns": ns_pick(r, "edge"), "pod": name_pick(r, "edge-proxy")},
|
||||
"task": CTX + """
|
||||
Exactly one worker node is labelled `tier=edge`. On that node create a **static pod**:
|
||||
@ -113,30 +92,30 @@ systemctl restart kubelet'
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 47, "domain": "ARCH", "difficulty": "hard", "title": "User onboarding to a working kubeconfig",
|
||||
"id": 104, "domain": "ARCH", "difficulty": "hard", "title": "User onboarding to a working kubeconfig",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H", "user": r.choice(["maya", "theo", "iris"]), "ns": ns_pick(r, "dev")},
|
||||
"task": CTX + """
|
||||
Onboard user `%{user}` (group `developers`) end to end:
|
||||
1. Key + CSR with CN=%{user}, O=developers; Kubernetes CSR object `%{user}`, approved, certificate issued.
|
||||
2. A **self-contained** kubeconfig at `{WS}/q47/%{user}.kubeconfig` (certs embedded, not file paths)
|
||||
2. A **self-contained** kubeconfig at `{WS}/q104/%{user}.kubeconfig` (certs embedded, not file paths)
|
||||
with a context `%{user}@lab` whose default namespace is `%{ns}`.
|
||||
3. `%{user}` may do everything to pods, deployments and services in `%{ns}` (edit-level), nothing in other namespaces.
|
||||
|
||||
`kubectl --kubeconfig {WS}/q47/%{user}.kubeconfig get pods` must work with no extra flags.
|
||||
`kubectl --kubeconfig {WS}/q104/%{user}.kubeconfig get pods` must work with no extra flags.
|
||||
""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q47"; mkdir -p "$WS/q47"; $K delete csr %{user} --ignore-not-found >/dev/null; $K -n %{ns} delete rolebinding --all >/dev/null 2>&1""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q104"; mkdir -p "$WS/q104"; $K delete csr %{user} --ignore-not-found >/dev/null; $K -n %{ns} delete rolebinding --all >/dev/null 2>&1""",
|
||||
"checks": [
|
||||
C("CSR %{user} approved with certificate, O=developers", 2, r"""[ -n "$(j csr %{user} '{.status.certificate}')" ] && j csr %{user} '{.status.certificate}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'O *= *developers'"""),
|
||||
C("kubeconfig is self-contained and works without flags", 3, r"""f="$WS/q47/%{user}.kubeconfig"; [ -f "$f" ] || exit 1; grep -q 'client-certificate-data' "$f" && grep -q 'certificate-authority-data' "$f" && ! grep -q 'client-certificate:' "$f" && kubectl --kubeconfig "$f" get pods >/dev/null 2>&1"""),
|
||||
C("context %{user}@lab defaults to %{ns}", 1, r"""eq "$(kubectl --kubeconfig "$WS/q47/%{user}.kubeconfig" config view -o jsonpath='{.contexts[?(@.name=="%{user}@lab")].context.namespace}')" %{ns}"""),
|
||||
C("edit rights in %{ns}, none elsewhere", 2, r"""eq "$($K auth can-i create deployments -n %{ns} --as=%{user} --as-group=developers 2>/dev/null)" yes && eq "$($K auth can-i delete pods -n %{ns} --as=%{user} --as-group=developers 2>/dev/null)" yes && eq "$($K auth can-i get pods -n default --as=%{user} --as-group=developers 2>/dev/null || true)" no && eq "$($K auth can-i get secrets -n kube-system --as=%{user} --as-group=developers 2>/dev/null || true)" no"""),
|
||||
C("kubeconfig is self-contained and works without flags", 3, r"""f="$WS/q104/%{user}.kubeconfig"; [ -f "$f" ] || exit 1; grep -q 'client-certificate-data' "$f" && grep -q 'certificate-authority-data' "$f" && ! grep -q 'client-certificate:' "$f" && kubectl --kubeconfig "$f" get pods >/dev/null 2>&1"""),
|
||||
C("context %{user}@lab defaults to %{ns}", 1, r"""eq "$(kubectl --kubeconfig "$WS/q104/%{user}.kubeconfig" config view -o jsonpath='{.contexts[?(@.name=="%{user}@lab")].context.namespace}')" %{ns}"""),
|
||||
C("edit rights in %{ns}, none elsewhere", 2, r"""eq "$($K auth can-i create deployments -n %{ns} --as=%{user} 2>/dev/null)" yes && eq "$($K auth can-i delete pods -n %{ns} --as=%{user} 2>/dev/null)" yes && eq "$($K auth can-i get pods -n default --as=%{user} 2>/dev/null || true)" no && eq "$($K auth can-i get secrets -n kube-system --as=%{user} 2>/dev/null || true)" no"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 48, "domain": "ARCH", "difficulty": "hard", "title": "Kustomize with generators and patches",
|
||||
"id": 105, "domain": "ARCH", "difficulty": "hard", "title": "Kustomize with generators and patches",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "kz"), "rep": r.choice([3, 4]), "lvl": r.choice(["debug", "trace", "warn"])},
|
||||
"task": CTX + """
|
||||
Base at `{WS}/q48/base` (Deployment `web`, Service `web`). Build overlay `{WS}/q48/overlays/prod` that:
|
||||
Base at `{WS}/q105/base` (Deployment `web`, Service `web`). Build overlay `{WS}/q105/overlays/prod` that:
|
||||
- targets namespace `%{ns}` and adds label `tier=prod` to all resources (labels only, not selectors),
|
||||
- sets replicas to %{rep} via a **strategic merge patch** file `replicas.yaml`,
|
||||
- adds a `configMapGenerator` named `web-config` with literal `LOG_LEVEL=%{lvl}` (hash suffix enabled),
|
||||
@ -145,11 +124,11 @@ Base at `{WS}/q48/base` (Deployment `web`, Service `web`). Build overlay `{WS}/q
|
||||
Apply with `kubectl apply -k`. The Deployment must reference the hashed ConfigMap name.
|
||||
""",
|
||||
"setup": r"""
|
||||
rm -rf "$WS/q48"; mkdir -p "$WS/q48/base"
|
||||
cat > "$WS/q48/base/kustomization.yaml" <<'EOF'
|
||||
rm -rf "$WS/q105"; mkdir -p "$WS/q105/base"
|
||||
cat > "$WS/q105/base/kustomization.yaml" <<'EOF'
|
||||
resources: [deployment.yaml, service.yaml]
|
||||
EOF
|
||||
cat > "$WS/q48/base/deployment.yaml" <<'EOF'
|
||||
cat > "$WS/q105/base/deployment.yaml" <<'EOF'
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: web}
|
||||
@ -161,7 +140,7 @@ spec:
|
||||
spec:
|
||||
containers: [{name: web, image: nginx:1.25, ports: [{containerPort: 80}]}]
|
||||
EOF
|
||||
cat > "$WS/q48/base/service.yaml" <<'EOF'
|
||||
cat > "$WS/q105/base/service.yaml" <<'EOF'
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: web}
|
||||
@ -170,61 +149,55 @@ EOF
|
||||
$K delete ns %{ns} --ignore-not-found --wait=false >/dev/null
|
||||
""",
|
||||
"checks": [
|
||||
C("overlay has replicas.yaml (SMP) and envfrom.yaml (JSON6902)", 1, r"""[ -f "$WS/q48/overlays/prod/replicas.yaml" ] && [ -f "$WS/q48/overlays/prod/envfrom.yaml" ] && grep -q 'op:' "$WS/q48/overlays/prod/envfrom.yaml" """),
|
||||
C("overlay has replicas.yaml (SMP) and envfrom.yaml (JSON6902)", 1, r"""[ -f "$WS/q105/overlays/prod/replicas.yaml" ] && [ -f "$WS/q105/overlays/prod/envfrom.yaml" ] && grep -q 'op:' "$WS/q105/overlays/prod/envfrom.yaml" """),
|
||||
C("deployed to %{ns}: %{rep} replicas, tier=prod label, selector untouched", 2, r"""d="$K -n %{ns} get deploy web"; eq "$($d -o jsonpath='{.spec.replicas}' 2>/dev/null)" %{rep} && eq "$($d -o jsonpath='{.metadata.labels.tier}')" prod && [ -z "$($d -o jsonpath='{.spec.selector.matchLabels.tier}')" ]"""),
|
||||
C("hashed ConfigMap with LOG_LEVEL=%{lvl}, referenced via envFrom", 3, r"""cm=$(j -n %{ns} deploy web '{.spec.template.spec.containers[0].envFrom[0].configMapRef.name}'); [ -n "$cm" ] && echo "$cm" | grep -Eq '^web-config-[a-z0-9]{6,}$' && eq "$(j -n %{ns} cm "$cm" '{.data.LOG_LEVEL}')" %{lvl}"""),
|
||||
C("pods available and env visible", 1, r"""eq "$(j -n %{ns} deploy web '{.status.availableReplicas}')" %{rep} && $K -n %{ns} exec deploy/web -- env 2>/dev/null | grep -q '^LOG_LEVEL=%{lvl}$'"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 49, "domain": "ARCH", "difficulty": "hard", "title": "CRD with validation and defaults",
|
||||
"id": 106, "domain": "ARCH", "difficulty": "hard", "title": "CRD with validation and defaults",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "kind": r.choice(["Rollout", "Canary", "Release"]), "env": r.choice(["prod", "stage"])},
|
||||
"task": CTX + """
|
||||
Create CRD `%{kind}` (group `deploy.example.com`, v1, Namespaced, plural = the kind in lowercase plus
|
||||
`s`, shortName = the first two letters of the kind in lowercase) with schema:
|
||||
Create CRD `%{kind}` (group `deploy.example.com`, v1, Namespaced, plural lowercase+s, shortName `%{kind}`-initials) with schema:
|
||||
- `spec.replicas` integer, **required**, minimum 1, maximum 10,
|
||||
- `spec.environment` string, enum `prod|stage|dev`, **default** `dev`,
|
||||
- `spec.image` string, required.
|
||||
Then:
|
||||
1. Create `%{kind}` `good` in `default` with replicas 3, image `nginx:1.27`, environment `%{env}`.
|
||||
2. Save the API server's rejection message for a `%{kind}` with `replicas: 0` to `{WS}/q49/rejected.txt`.
|
||||
3. Create `%{kind}` `defaulted` in `default` with only replicas 2 and image set; its environment
|
||||
must be `dev` after creation.
|
||||
|
||||
This context's default namespace is not necessarily `default` — say `-n default` explicitly.
|
||||
2. Save the API server's rejection message for a `%{kind}` with `replicas: 0` to `{WS}/q106/rejected.txt`.
|
||||
3. Create `%{kind}` `defaulted` with only replicas 2 and image set; its environment must be `dev` after creation.
|
||||
""",
|
||||
"setup": r"""rm -rf "$WS/q49"; mkdir -p "$WS/q49"; k=$(echo %{kind} | tr A-Z a-z); $K delete crd ${k}s.deploy.example.com --ignore-not-found >/dev/null""",
|
||||
"setup": r"""rm -rf "$WS/q106"; mkdir -p "$WS/q106"; k=$(echo %{kind} | tr A-Z a-z); $K delete crd ${k}s.deploy.example.com --ignore-not-found >/dev/null""",
|
||||
"checks": [
|
||||
C("CRD exists, Namespaced, replicas has min/max and is required", 2, r"""s=""; k=$(echo %{kind} | tr A-Z a-z); c="$K get crd ${k}s.deploy.example.com"; eq "$($c -o jsonpath='{.spec.scope}' 2>/dev/null)" Namespaced && s=$($c -o jsonpath='{.spec.versions[0].schema.openAPIV3Schema.properties.spec}'); echo "$s" | grep -q '"minimum":1' && echo "$s" | grep -q '"maximum":10' && echo "$s" | grep -q '"required":\[' """),
|
||||
C("CRD exists, Namespaced, replicas has min/max and is required", 2, r"""k=$(echo %{kind} | tr A-Z a-z); c="$K get crd ${k}s.deploy.example.com"; eq "$($c -o jsonpath='{.spec.scope}' 2>/dev/null)" Namespaced && s=$($c -o jsonpath='{.spec.versions[0].schema.openAPIV3Schema.properties.spec}'); echo "$s" | grep -q '"minimum":1' && echo "$s" | grep -q '"maximum":10' && echo "$s" | grep -q '"required":\[' """),
|
||||
C("good created with environment %{env}", 1, r"""k=$(echo %{kind} | tr A-Z a-z); eq "$(j -n default ${k}s.deploy.example.com good '{.spec.environment}')" %{env}"""),
|
||||
C("replicas 0 is rejected and the message was saved", 2, r"""grep -qiE 'minimum|greater than|>= 1|invalid' "$WS/q49/rejected.txt" 2>/dev/null"""),
|
||||
C("replicas 0 is rejected and the message was saved", 2, r"""grep -qiE 'minimum|greater than|>= 1|invalid' "$WS/q106/rejected.txt" 2>/dev/null"""),
|
||||
C("defaulting works: defaulted.spec.environment == dev", 2, r"""k=$(echo %{kind} | tr A-Z a-z); eq "$(j -n default ${k}s.deploy.example.com defaulted '{.spec.environment}')" dev"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 65, "domain": "ARCH", "breaks": "certs", "difficulty": "hard", "title": "Renew control-plane certificates",
|
||||
"id": 122, "domain": "ARCH", "breaks": "certs", "difficulty": "hard", "title": "Renew control-plane certificates",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H"},
|
||||
"task": CTX + """
|
||||
Security policy requires the control-plane certificates to be renewed now.
|
||||
On the control-plane node, renew **all** kubeadm-managed certificates, make every control-plane
|
||||
component pick up the new certificates, and update the admin kubeconfig on the node
|
||||
(`/etc/kubernetes/admin.conf`) so it uses the renewed client certificate.
|
||||
Write the new `apiserver` certificate's expiry (as printed by `kubeadm certs check-expiration`) to `{WS}/q65/expiry.txt`.
|
||||
Write the new `apiserver` certificate's expiry (as printed by `kubeadm certs check-expiration`) to `{WS}/q122/expiry.txt`.
|
||||
""",
|
||||
"setup": r"""rm -rf "$WS/q65"; mkdir -p "$WS/q65"; nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' > "$WS/.q65-before" """,
|
||||
"setup": r"""rm -rf "$WS/q122"; mkdir -p "$WS/q122"; nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' > "$WS/.q122-before" """,
|
||||
"checks": [
|
||||
C("apiserver certificate was renewed (newer notBefore)", 3, r"""b=$(sed 's/notBefore=//' "$WS/.q65-before"); a=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' | sed 's/notBefore=//'); [ "$(date -d "$a" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
|
||||
C("apiserver certificate was renewed (newer notBefore)", 3, r"""b=$(sed 's/notBefore=//' "$WS/.q122-before"); a=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -startdate' | sed 's/notBefore=//'); [ "$(date -d "$a" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
|
||||
C("all components restarted and Running with the new certs (apiserver serving cert matches file)", 3, r"""
|
||||
for c in kube-apiserver kube-scheduler kube-controller-manager etcd; do eq "$(j -n kube-system pod -l component=$c '{.items[0].status.phase}')" Running || exit 1; done
|
||||
srv=$(nx "$CP" 'echo | openssl s_client -connect 127.0.0.1:6443 2>/dev/null | openssl x509 -noout -serial -startdate'); f=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -serial -startdate'); [ -n "$srv" ] && eq "$srv" "$f" || exit 1
|
||||
# ...and it is the RENEWED certificate: an untouched cluster also serves the file it has on disk
|
||||
b=$(sed 's/notBefore=//' "$WS/.q65-before"); a=$(echo "$srv" | sed -n 's/notBefore=//p'); [ "$(date -d "$a" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
|
||||
C("admin.conf uses the renewed client cert", 2, r"""nx "$CP" 'grep client-certificate-data /etc/kubernetes/admin.conf | awk "{print \$2}" | base64 -d | openssl x509 -noout -startdate' | sed 's/notBefore=//' > /tmp/q65a; b=$(sed 's/notBefore=//' "$WS/.q65-before"); [ "$(date -d "$(cat /tmp/q65a)" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
|
||||
C("expiry.txt matches kubeadm's report for apiserver", 1, r"""exp=$(nx "$CP" 'kubeadm certs check-expiration 2>/dev/null' | awk '$1=="apiserver"{print $2,$3,$4,$5,$6}'); [ -n "$exp" ] && grep -qF "$exp" "$WS/q65/expiry.txt" 2>/dev/null"""),
|
||||
srv=$(nx "$CP" 'echo | openssl s_client -connect 127.0.0.1:6443 2>/dev/null | openssl x509 -noout -serial'); f=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -serial'); [ -n "$srv" ] && eq "$srv" "$f" """),
|
||||
C("admin.conf uses the renewed client cert", 2, r"""nx "$CP" 'grep client-certificate-data /etc/kubernetes/admin.conf | awk "{print \$2}" | base64 -d | openssl x509 -noout -startdate' | sed 's/notBefore=//' > /tmp/q122a; b=$(sed 's/notBefore=//' "$WS/.q122-before"); [ "$(date -d "$(cat /tmp/q122a)" +%s)" -gt "$(date -d "$b" +%s)" ]"""),
|
||||
C("expiry.txt matches kubeadm's report for apiserver", 1, r"""exp=$(nx "$CP" 'kubeadm certs check-expiration 2>/dev/null' | awk '$1=="apiserver"{print $2,$3,$4,$5,$6}'); [ -n "$exp" ] && grep -qF "$exp" "$WS/q122/expiry.txt" 2>/dev/null"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 67, "domain": "ARCH", "difficulty": "hard", "title": "Prepare a node join without shell access to the control plane",
|
||||
"id": 124, "domain": "ARCH", "difficulty": "hard", "title": "Prepare a node join without shell access to the control plane",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H"},
|
||||
"task": CTX + """
|
||||
A new worker node is about to join this cluster, but you can only hand the networking team a
|
||||
@ -237,7 +210,7 @@ A new worker node is about to join this cluster, but you can only hand the netwo
|
||||
3. Determine the API server's real address as seen **from inside the cluster network** — not the
|
||||
`127.0.0.1:<port>` your local kubeconfig uses to reach it from this machine, which a joining
|
||||
node cannot use.
|
||||
4. Write exactly these three lines to `{WS}/q67/node-join.env`:
|
||||
4. Write exactly these three lines to `{WS}/q124/node-join.env`:
|
||||
```
|
||||
JOIN_TOKEN=<token>
|
||||
CA_HASH=sha256:<hash>
|
||||
@ -247,27 +220,27 @@ Every value must be genuine: plugged into `kubeadm join $API_ENDPOINT --token $J
|
||||
--discovery-token-ca-cert-hash $CA_HASH` right now, it would work.
|
||||
""",
|
||||
"setup": r"""
|
||||
rm -rf "$WS/q67"; mkdir -p "$WS/q67"
|
||||
rm -rf "$WS/q124"; mkdir -p "$WS/q124"
|
||||
for t in $($K -n kube-system get secrets -o jsonpath='{.items[?(@.type=="bootstrap.kubernetes.io/token")].metadata.name}'); do $K -n kube-system delete secret $t >/dev/null; done
|
||||
""",
|
||||
"checks": [
|
||||
C("a real, unexpired bootstrap token", 2, r"""
|
||||
tok=$(grep -oE '^JOIN_TOKEN=[a-z0-9]{6}\.[a-z0-9]{16}' "$WS/q67/node-join.env" 2>/dev/null | cut -d= -f2); [ -n "$tok" ] || exit 1
|
||||
tok=$(grep -oE '^JOIN_TOKEN=[a-z0-9]{6}\.[a-z0-9]{16}' "$WS/q124/node-join.env" 2>/dev/null | cut -d= -f2); [ -n "$tok" ] || exit 1
|
||||
id=${tok%%.*}; exp=$(j -n kube-system secret bootstrap-token-$id '{.data.expiration}' | base64 -d 2>/dev/null); [ -n "$exp" ] || exit 1
|
||||
[ "$(date -d "$exp" +%s)" -gt "$(date +%s)" ]"""),
|
||||
C("CA_HASH computed correctly (matches the real CA's SPKI SHA-256)", 3, r"""
|
||||
want=$(nx "$CP" "openssl x509 -pubkey -in /etc/kubernetes/pki/ca.crt | openssl rsa -pubin -outform der 2>/dev/null | openssl dgst -sha256 -hex | sed 's/^.* //'")
|
||||
got=$(grep -oE '^CA_HASH=sha256:[0-9a-f]{64}' "$WS/q67/node-join.env" 2>/dev/null | cut -d= -f2 | cut -d: -f2)
|
||||
got=$(grep -oE '^CA_HASH=sha256:[0-9a-f]{64}' "$WS/q124/node-join.env" 2>/dev/null | cut -d= -f2 | cut -d: -f2)
|
||||
[ -n "$want" ] && eq "$got" "$want" """),
|
||||
C("API_ENDPOINT is the real in-cluster address, not the local kubeconfig's", 2, r"""
|
||||
want=$($K get endpointslices -l kubernetes.io/service-name=kubernetes -o jsonpath='{.items[0].endpoints[0].addresses[0]}:{.items[0].ports[0].port}' 2>/dev/null)
|
||||
got=$(grep -oE '^API_ENDPOINT=.*' "$WS/q67/node-join.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
|
||||
got=$(grep -oE '^API_ENDPOINT=.*' "$WS/q124/node-join.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]')
|
||||
[ -n "$want" ] && eq "$got" "$want" """),
|
||||
],
|
||||
},
|
||||
# ================================================================ WORK ====
|
||||
{
|
||||
"id": 50, "domain": "WORK", "breaks": "w1-taint", "difficulty": "hard", "title": "Spread, tolerate, prioritise — under a quota",
|
||||
"id": 107, "domain": "WORK", "breaks": "w1-taint", "difficulty": "hard", "title": "Spread, tolerate, prioritise — under a quota",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "spread"), "dep": name_pick(r, "gateway"), "key": r.choice(["pool", "lane"]), "val": r.choice(["ingress", "edge"])},
|
||||
"task": CTX + """
|
||||
Node `{W1}` is tainted `%{key}=%{val}:NoSchedule`. In namespace `%{ns}` create Deployment `%{dep}`
|
||||
@ -299,7 +272,7 @@ EOF
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 51, "domain": "WORK", "difficulty": "hard", "title": "Rollback to a specific revision, then pause",
|
||||
"id": 108, "domain": "WORK", "difficulty": "hard", "title": "Rollback to a specific revision, then pause",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "rel"), "dep": name_pick(r, "portal")},
|
||||
"task": CTX + """
|
||||
Deployment `%{dep}` in `%{ns}` has gone through several releases; the current one is broken.
|
||||
@ -318,15 +291,9 @@ $K -n %{ns} set image deploy %{dep} nginx=nginx:1.27 >/dev/null; $K -n %{ns} rol
|
||||
$K -n %{ns} set image deploy %{dep} nginx=nginx:9.99-broken >/dev/null
|
||||
""",
|
||||
"checks": [
|
||||
C("running pods use nginx:1.25 and 3 are available", 3, r"""
|
||||
for t in $(seq 1 15); do # pods of the replaced ReplicaSets linger for a moment while they terminate
|
||||
ok=1; eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 || ok=0
|
||||
for i in $(j -n %{ns} pod -l app=%{dep} '{.items[*].spec.containers[0].image}'); do eq "$i" nginx:1.25 || ok=0; done
|
||||
[ $ok = 1 ] && exit 0; sleep 2
|
||||
done; exit 1"""),
|
||||
C("running pods use nginx:1.25 and 3 are available", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 && for i in $(j -n %{ns} pod -l app=%{dep} '{.items[*].spec.containers[0].image}'); do eq "$i" nginx:1.25 || exit 1; done"""),
|
||||
C("deployment is paused with template image nginx:1.27 and RELEASE=next", 2, r"""d="$K -n %{ns} get deploy %{dep}"; eq "$($d -o jsonpath='{.spec.paused}' 2>/dev/null)" true && eq "$($d -o jsonpath='{.spec.template.spec.containers[0].image}')" nginx:1.27 && eq "$($d -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="RELEASE")].value}')" next"""),
|
||||
C("no ReplicaSet exists for the paused template (1.27 + RELEASE)", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].image}')" nginx:1.27 || exit 1
|
||||
! $K -n %{ns} get rs -o json 2>/dev/null | python3 -c '
|
||||
C("no ReplicaSet exists for the paused template (1.27 + RELEASE)", 2, r"""! $K -n %{ns} get rs -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
for rs in json.load(sys.stdin)["items"]:
|
||||
c=rs["spec"]["template"]["spec"]["containers"][0]
|
||||
@ -335,108 +302,53 @@ sys.exit(1)'"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 52, "domain": "WORK", "difficulty": "hard", "title": "Native sidecar with probes and an exact report",
|
||||
"id": 109, "domain": "WORK", "difficulty": "hard", "title": "Native sidecar with probes and an exact report",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "side"), "pod": name_pick(r, "collector")},
|
||||
"task": CTX + """
|
||||
Create pod `%{pod}` in `%{ns}`:
|
||||
- a **native sidecar** (an init container with `restartPolicy: Always`) named `agent` (busybox:1.36)
|
||||
that appends `$(date)` to `/var/log/agent/agent.log` every second,
|
||||
that appends `$(date)` to `/var/log/agent.log` every second,
|
||||
- main container `app` (nginx:1.27) with a readiness probe (HTTP GET / on 80, period 5s)
|
||||
and a liveness probe (TCP 80),
|
||||
- shared `emptyDir` `logs` mounted at `/var/log/agent` in both (not at `/var/log`: nginx keeps its
|
||||
own logs under `/var/log/nginx`, and an emptyDir mounted over `/var/log` hides that directory),
|
||||
- shared `emptyDir` `logs` mounted at `/var/log` in both,
|
||||
- `terminationGracePeriodSeconds: 5`.
|
||||
When it is Running, write the names of **all** containers of the pod (init and regular), sorted,
|
||||
one per line, to `{WS}/q52/containers.txt`, and the pod's `.status.podIP` to `{WS}/q52/ip.txt`.
|
||||
one per line, to `{WS}/q109/containers.txt`, and the pod's `.status.podIP` to `{WS}/q109/ip.txt`.
|
||||
""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q52"; mkdir -p "$WS/q52"; $K -n %{ns} delete pod %{pod} --ignore-not-found --force --grace-period=0 --wait=true >/dev/null 2>&1""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q109"; mkdir -p "$WS/q109"; $K -n %{ns} delete pod %{pod} --ignore-not-found --wait=false >/dev/null""",
|
||||
"checks": [
|
||||
C("native sidecar: initContainer agent with restartPolicy Always", 2, r"""eq "$(j -n %{ns} pod %{pod} '{.spec.initContainers[?(@.name=="agent")].restartPolicy}')" Always"""),
|
||||
C("probes on app as specified", 2, r"""c='{.spec.containers[?(@.name=="app")]'; eq "$(j -n %{ns} pod %{pod} "$c.readinessProbe.httpGet.path}")" / && eq "$(j -n %{ns} pod %{pod} "$c.readinessProbe.periodSeconds}")" 5 && eq "$(j -n %{ns} pod %{pod} "$c.livenessProbe.tcpSocket.port}")" 80"""),
|
||||
C("Running, shared log written by agent, grace period 5", 2, r"""eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && eq "$(j -n %{ns} pod %{pod} '{.spec.terminationGracePeriodSeconds}')" 5 && [ -n "$($K -n %{ns} exec %{pod} -c app -- sh -c 'tail -1 /var/log/agent/agent.log' 2>/dev/null)" ]"""),
|
||||
C("report files exact", 2, r"""want=$( (j -n %{ns} pod %{pod} '{.spec.initContainers[*].name}'; echo; j -n %{ns} pod %{pod} '{.spec.containers[*].name}') | tr ' ' '\n' | grep . | sort); [ -n "$want" ] && [ "$(grep . "$WS/q52/containers.txt" 2>/dev/null | sort)" = "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q52/ip.txt" 2>/dev/null)" "$(j -n %{ns} pod %{pod} '{.status.podIP}')" """),
|
||||
C("Running, shared log written by agent, grace period 5", 2, r"""eq "$(j -n %{ns} pod %{pod} '{.status.phase}')" Running && eq "$(j -n %{ns} pod %{pod} '{.spec.terminationGracePeriodSeconds}')" 5 && [ -n "$($K -n %{ns} exec %{pod} -c app -- sh -c 'tail -1 /var/log/agent.log' 2>/dev/null)" ]"""),
|
||||
C("report files exact", 2, r"""want=$( (j -n %{ns} pod %{pod} '{.spec.initContainers[*].name}'; echo; j -n %{ns} pod %{pod} '{.spec.containers[*].name}') | tr ' ' '\n' | grep . | sort); [ "$(grep . "$WS/q109/containers.txt" 2>/dev/null | sort)" = "$want" ] && eq "$(tr -d '[:space:]' < "$WS/q109/ip.txt" 2>/dev/null)" "$(j -n %{ns} pod %{pod} '{.status.podIP}')" """),
|
||||
],
|
||||
},
|
||||
{
|
||||
# Scoped to its own namespace: the cluster-wide version compared a file written mid-exam with the
|
||||
# whole cluster at submit time, so every pod another task (or a grader probe) created or deleted
|
||||
# in between failed it. killer.sh-style exact-output tasks work on data the task owns.
|
||||
"id": 53, "domain": "WORK", "difficulty": "hard", "title": "Namespace audit to exact files",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H", "ns": ns_pick(r, "audit")},
|
||||
"id": 110, "domain": "WORK", "difficulty": "hard", "title": "Cluster audit to exact files",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H"},
|
||||
"task": CTX + """
|
||||
Namespace `%{ns}` runs a mix of workloads. Produce three files with contents **exactly** as
|
||||
specified — no headers, one entry per line, sorted with `sort`:
|
||||
1. `{WS}/q53/no-readiness.txt` — the name of every Pod in `%{ns}` whose **first** container has
|
||||
no readiness probe (whatever the Pod's phase).
|
||||
2. `{WS}/q53/per-node.txt` — `<node> <count>`: how many Pods in `%{ns}` are **Running** on each
|
||||
node, listing only nodes that have at least one.
|
||||
3. `{WS}/q53/images.txt` — every distinct container image used by Pods in `kube-system`.
|
||||
""",
|
||||
"setup": NS + r"""
|
||||
mkns %{ns}; rm -rf "$WS/q53"; mkdir -p "$WS/q53"
|
||||
$K -n %{ns} delete pod --all --force --grace-period=0 --wait=true >/dev/null 2>&1
|
||||
$K apply -f - >/dev/null <<EOF
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: web-a, namespace: %{ns}}
|
||||
spec:
|
||||
nodeSelector: {kubernetes.io/hostname: $W1}
|
||||
containers: [{name: web, image: nginx:1.27, readinessProbe: {httpGet: {path: /, port: 80}}}]
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: web-b, namespace: %{ns}}
|
||||
spec:
|
||||
nodeSelector: {kubernetes.io/hostname: $W2}
|
||||
containers: [{name: web, image: nginx:1.27, readinessProbe: {tcpSocket: {port: 80}}}]
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: batch-a, namespace: %{ns}}
|
||||
spec:
|
||||
nodeSelector: {kubernetes.io/hostname: $W1}
|
||||
containers: [{name: batch, image: busybox:1.36, command: [sleep, "86400"]}]
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: cache-a, namespace: %{ns}}
|
||||
spec:
|
||||
nodeSelector: {kubernetes.io/hostname: $W3}
|
||||
containers:
|
||||
- {name: cache, image: redis:7-alpine}
|
||||
- {name: exporter, image: nginx:1.27, readinessProbe: {httpGet: {path: /, port: 80}}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: once-a, namespace: %{ns}}
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
nodeSelector: {kubernetes.io/hostname: $W3}
|
||||
containers: [{name: job, image: busybox:1.36, command: ["true"]}]
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: idle-a, namespace: %{ns}}
|
||||
spec:
|
||||
nodeSelector: {kubernetes.io/hostname: $W2}
|
||||
containers: [{name: idle, image: busybox:1.36, command: [sleep, "86400"]}]
|
||||
EOF
|
||||
for i in $(seq 1 60); do [ "$($K -n %{ns} get pod once-a -o jsonpath='{.status.phase}' 2>/dev/null)" = Succeeded ] && break; sleep 2; done
|
||||
Produce three files, contents **exactly** as specified (no headers, sorted with `sort`, one per line):
|
||||
1. `{WS}/q110/no-readiness.txt` — `<namespace>/<pod>` for every pod in the cluster whose first
|
||||
container has **no** readiness probe. Exclude namespace `kube-system`.
|
||||
2. `{WS}/q110/per-node.txt` — `<node> <count>` of Running pods per node, all namespaces.
|
||||
3. `{WS}/q110/images.txt` — every distinct container image running in namespace `kube-system`.
|
||||
""",
|
||||
"setup": r"""rm -rf "$WS/q110"; mkdir -p "$WS/q110" """,
|
||||
"checks": [
|
||||
C("no-readiness.txt exact (first container only, every phase)", 3, r"""want=$($K -n %{ns} get pods -o json 2>/dev/null | python3 -c '
|
||||
C("no-readiness.txt exact", 3, r"""want=$($K get pods -A -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
for p in json.load(sys.stdin)["items"]:
|
||||
if not p["spec"]["containers"][0].get("readinessProbe"): print(p["metadata"]["name"])' | sort); [ -n "$want" ] && [ "$(grep . "$WS/q53/no-readiness.txt" 2>/dev/null)" = "$want" ]"""),
|
||||
C("per-node.txt exact (Running pods only)", 2, r"""want=$($K -n %{ns} get pods -o json 2>/dev/null | python3 -c '
|
||||
if p["metadata"]["namespace"]=="kube-system": continue
|
||||
if not p["spec"]["containers"][0].get("readinessProbe"): print(p["metadata"]["namespace"]+"/"+p["metadata"]["name"])' | sort); [ "$(grep . "$WS/q110/no-readiness.txt" 2>/dev/null | sort)" = "$want" ]"""),
|
||||
C("per-node.txt exact", 2, r"""want=$($K get pods -A -o json 2>/dev/null | python3 -c '
|
||||
import sys,json,collections
|
||||
c=collections.Counter(p["spec"].get("nodeName") for p in json.load(sys.stdin)["items"] if p["status"]["phase"]=="Running" and p["spec"].get("nodeName"))
|
||||
for n in c: print(n, c[n])' | sort); [ -n "$want" ] && [ "$(grep . "$WS/q53/per-node.txt" 2>/dev/null)" = "$want" ]"""),
|
||||
C("images.txt exact", 2, r"""want=$(j -n kube-system pods '{.items[*].spec.containers[*].image}' | tr ' ' '\n' | sort -u); [ -n "$want" ] && [ "$(grep . "$WS/q53/images.txt" 2>/dev/null)" = "$want" ]"""),
|
||||
for n in sorted(c): print(n, c[n])'); [ "$(grep . "$WS/q110/per-node.txt" 2>/dev/null | sort)" = "$(echo "$want" | sort)" ]"""),
|
||||
C("images.txt exact", 2, r"""want=$(j -n kube-system pods '{.items[*].spec.containers[*].image}' | tr ' ' '\n' | sort -u); [ "$(grep . "$WS/q110/images.txt" 2>/dev/null | sort -u)" = "$want" ]"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 64, "domain": "WORK", "difficulty": "hard", "title": "The context's namespace",
|
||||
"id": 121, "domain": "WORK", "difficulty": "hard", "title": "The context's namespace",
|
||||
# ctx_ns: this task reads the context's own default namespace instead of being told one, so
|
||||
# k8s-c2-AC must be created pointing at *this* task's ns (see context_namespace in ./exam).
|
||||
"ctx_ns": True,
|
||||
@ -445,19 +357,19 @@ for n in c: print(n, c[n])' | sort); [ -n "$want" ] && [ "$(grep . "$WS/q53/per-
|
||||
In the **current namespace of that context** (do not pass `-n`), create Deployment `%{dep}`
|
||||
(busybox:1.36, `sleep 3600`, 2 replicas, label `owner=ac`) and a ClusterIP Service `%{dep}`
|
||||
on port 9000 targeting port 9000. Then write the namespace name you deployed into, as reported
|
||||
by `kubectl config view --minify`, to `{WS}/q64/ns.txt`.
|
||||
by `kubectl config view --minify`, to `{WS}/q121/ns.txt`.
|
||||
""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q64"; mkdir -p "$WS/q64"; $K -n %{ns} delete deploy,svc %{dep} --ignore-not-found >/dev/null; $K -n default delete deploy,svc %{dep} --ignore-not-found >/dev/null""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q121"; mkdir -p "$WS/q121"; $K -n %{ns} delete deploy,svc %{dep} --ignore-not-found >/dev/null; $K -n default delete deploy,svc %{dep} --ignore-not-found >/dev/null""",
|
||||
"checks": [
|
||||
C("Deployment %{dep} in %{ns} (the context's namespace), 2 available, owner=ac", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 2 && { eq "$(j -n %{ns} deploy %{dep} '{.metadata.labels.owner}')" ac || eq "$(j -n %{ns} deploy %{dep} '{.spec.template.metadata.labels.owner}')" ac; }"""),
|
||||
C("Deployment %{dep} in %{ns} (the context's namespace), 2 available, owner=ac", 3, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 2 && eq "$(j -n %{ns} deploy %{dep} '{.spec.template.metadata.labels.owner}')" ac"""),
|
||||
C("Service %{dep} port 9000 in the same namespace", 1, r"""eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].port}')" 9000"""),
|
||||
C("nothing was created in namespace default by mistake", 1, r"""$K -n %{ns} get deploy %{dep} >/dev/null 2>&1 && ! $K -n default get deploy %{dep} >/dev/null 2>&1"""),
|
||||
C("ns.txt has the namespace", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q64/ns.txt" 2>/dev/null)" %{ns}"""),
|
||||
C("nothing was created in namespace default by mistake", 1, r"""! $K -n default get deploy %{dep} >/dev/null 2>&1"""),
|
||||
C("ns.txt has the namespace", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q121/ns.txt" 2>/dev/null)" %{ns}"""),
|
||||
],
|
||||
},
|
||||
# ================================================================= NET ====
|
||||
{
|
||||
"id": 54, "domain": "NET", "difficulty": "hard", "title": "Zero-trust namespace",
|
||||
"id": 111, "domain": "NET", "difficulty": "hard", "title": "Zero-trust namespace",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "zt"), "from": ns_pick(r, "front")},
|
||||
"task": CTX + """
|
||||
Namespace `%{ns}` runs `api` (label `app=api`, port 8080) and `db` (label `app=db`, port 5432).
|
||||
@ -504,7 +416,7 @@ sys.exit(0 if eg and ing else 1)'"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 55, "domain": "NET", "difficulty": "hard", "title": "TLS Ingress with a generated certificate",
|
||||
"id": 112, "domain": "NET", "difficulty": "hard", "title": "TLS Ingress with a generated certificate",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "tls"), "host": r.choice(["secure.lab.local", "portal.lab.local", "admin.lab.local"])},
|
||||
"task": CTX + """
|
||||
Namespace `%{ns}` has Deployment+Service `web` (port 80).
|
||||
@ -512,17 +424,17 @@ Namespace `%{ns}` has Deployment+Service `web` (port 80).
|
||||
Secret `%{host}-tls` of type `kubernetes.io/tls`.
|
||||
2. Create Ingress `web` (class `nginx`) serving host `%{host}` with that TLS secret, path `/` Prefix → `web:80`,
|
||||
and the annotation `nginx.ingress.kubernetes.io/ssl-redirect: "true"`.
|
||||
3. Write the certificate's SHA-256 fingerprint (`openssl x509 -fingerprint -sha256`, the value only) to `{WS}/q55/fp.txt`.
|
||||
3. Write the certificate's SHA-256 fingerprint (`openssl x509 -fingerprint -sha256`, the value only) to `{WS}/q112/fp.txt`.
|
||||
""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q55"; mkdir -p "$WS/q55"; $K -n %{ns} create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} delete secret %{host}-tls --ignore-not-found >/dev/null; $K -n %{ns} delete ingress web --ignore-not-found >/dev/null""",
|
||||
"setup": NS + r"""mkns %{ns}; rm -rf "$WS/q112"; mkdir -p "$WS/q112"; $K -n %{ns} create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} delete secret %{host}-tls --ignore-not-found >/dev/null; $K -n %{ns} delete ingress web --ignore-not-found >/dev/null""",
|
||||
"checks": [
|
||||
C("Secret %{host}-tls is kubernetes.io/tls with CN %{host}", 2, r"""eq "$(j -n %{ns} secret %{host}-tls '{.type}')" kubernetes.io/tls && j -n %{ns} secret %{host}-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'CN *= *%{host}'"""),
|
||||
C("Ingress web: class nginx, host, TLS secret, ssl-redirect annotation", 3, r"""i="$K -n %{ns} get ingress web"; eq "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" nginx && eq "$($i -o jsonpath='{.spec.tls[0].secretName}')" %{host}-tls && eq "$($i -o jsonpath='{.spec.tls[0].hosts[0]}')" %{host} && eq "$($i -o jsonpath='{.spec.rules[0].host}')" %{host} && eq "$($i -o jsonpath='{.spec.rules[0].http.paths[0].backend.service.name}')" web && eq "$($i -o jsonpath='{.metadata.annotations.nginx\.ingress\.kubernetes\.io/ssl-redirect}')" true"""),
|
||||
C("fp.txt matches the secret's certificate fingerprint", 2, r"""fp=$(j -n %{ns} secret %{host}-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//'); [ -n "$fp" ] && grep -qiF "$fp" "$WS/q55/fp.txt" 2>/dev/null"""),
|
||||
C("fp.txt matches the secret's certificate fingerprint", 2, r"""fp=$(j -n %{ns} secret %{host}-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -fingerprint -sha256 2>/dev/null | sed 's/.*=//'); [ -n "$fp" ] && grep -qiF "$fp" "$WS/q112/fp.txt" 2>/dev/null"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 56, "domain": "NET", "difficulty": "hard", "title": "Gateway API canary with header match",
|
||||
"id": 113, "domain": "NET", "difficulty": "hard", "title": "Gateway API canary with header match",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "canary"), "host": r.choice(["shop", "pay", "cart"]), "w": r.choice([10, 20, 25])},
|
||||
"task": CTX + """
|
||||
Gateway API CRDs are installed. Namespace `%{ns}` has Services `v1` and `v2` (port 80).
|
||||
@ -536,7 +448,7 @@ Create in `%{ns}`:
|
||||
"params_post": None,
|
||||
"setup": NS + r"""
|
||||
mkns %{ns}
|
||||
ensure_gateway_crds || echo "warn: could not install the Gateway API CRDs (no network?) - the task needs them"
|
||||
$K get crd gateways.gateway.networking.k8s.io >/dev/null 2>&1 || { curl -sfL --max-time 60 https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml -o /tmp/gwapi.yaml && $K apply -f /tmp/gwapi.yaml >/dev/null 2>&1; } || echo "warn: could not install Gateway API CRDs"
|
||||
for v in v1 v2; do $K -n %{ns} create deploy $v --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; $K -n %{ns} expose deploy $v --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null; done
|
||||
$K delete gatewayclass %{host}-gc --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete gateway %{host}-gw --ignore-not-found >/dev/null 2>&1; $K -n %{ns} delete httproute %{host}-route --ignore-not-found >/dev/null 2>&1
|
||||
""",
|
||||
@ -549,12 +461,12 @@ ok=any(any(any(h.get("name","").lower()=="x-canary" and h.get("value")=="always"
|
||||
sys.exit(0 if ok else 1)'"""),
|
||||
C("default rule weights v1 %{w2} / v2 %{w}", 2, r"""$K -n %{ns} get httproute %{host}-route -o json 2>/dev/null | python3 -c '
|
||||
import sys,json; r=json.load(sys.stdin)["spec"]["rules"]
|
||||
ok=any((not x.get("matches") or all(not m.get("headers") for m in x["matches"])) and {b["name"]:b.get("weight") for b in x["backendRefs"]}=={"v1":%{w2},"v2":%{w}} for x in r if len(x.get("backendRefs",[]))==2)
|
||||
ok=any(not x.get("matches") or all(not m.get("headers") for m in x["matches"]) and {b["name"]:b.get("weight") for b in x["backendRefs"]}=={"v1":%{w2},"v2":%{w}} for x in r if len(x.get("backendRefs",[]))==2)
|
||||
sys.exit(0 if ok else 1)'"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 57, "domain": "NET", "breaks": "coredns", "difficulty": "hard", "title": "DNS down: three faults", "disruptive": True,
|
||||
"id": 114, "domain": "NET", "breaks": "coredns", "difficulty": "hard", "title": "DNS down: three faults", "disruptive": True,
|
||||
"params": lambda r: {"ctx": "k8s-c1-H"},
|
||||
"task": CTX + """
|
||||
Nothing in the cluster resolves DNS. Restore it. There is more than one cause.
|
||||
@ -564,9 +476,9 @@ succeed from a pod in `default` (use the FQDN: busybox's nslookup does not apply
|
||||
`kube-system`.
|
||||
""",
|
||||
"setup": r"""
|
||||
$K -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' > /tmp/Corefile.q57
|
||||
sed -i 's|forward \. /etc/resolv.conf|forward . 10.255.255.254|' /tmp/Corefile.q57
|
||||
$K -n kube-system create cm coredns --from-file=Corefile=/tmp/Corefile.q57 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' > /tmp/Corefile.q114
|
||||
sed -i 's|forward \. /etc/resolv.conf|forward . 10.255.255.254|' /tmp/Corefile.q114
|
||||
$K -n kube-system create cm coredns --from-file=Corefile=/tmp/Corefile.q114 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n kube-system scale deploy coredns --replicas=0 >/dev/null
|
||||
$K apply -f - >/dev/null <<EOF
|
||||
apiVersion: networking.k8s.io/v1
|
||||
@ -588,7 +500,7 @@ $K -n default delete pod dns-probe --wait=false >/dev/null; exit 1"""),
|
||||
},
|
||||
# ================================================================ STOR ====
|
||||
{
|
||||
"id": 58, "domain": "STOR", "difficulty": "hard", "title": "Bind the right local volume",
|
||||
"id": 115, "domain": "STOR", "difficulty": "hard", "title": "Bind the right local volume",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "lv"), "pvc": name_pick(r, "fast-data")},
|
||||
"task": CTX + """
|
||||
Two PersistentVolumes of class `local-fast` exist. Only one of them lives on the node labelled
|
||||
@ -601,18 +513,7 @@ mkns %{ns}
|
||||
for n in "$W1" "$W2" "$W3"; do $K label node $n disk- >/dev/null 2>&1 || true; done
|
||||
$K label node "$W2" disk=nvme --overwrite >/dev/null
|
||||
$K -n %{ns} delete pod consumer --ignore-not-found --wait=false >/dev/null; $K -n %{ns} delete pvc %{pvc} --ignore-not-found >/dev/null
|
||||
# The PV names are fixed but every session draws a new namespace, so a claim from an earlier run
|
||||
# (another namespace, often with its pod still running) keeps lv-hdd/lv-nvme Bound. pv-protection
|
||||
# then holds `kubectl delete pv` forever and the seed timed out. Release them from whoever has them.
|
||||
for pv in lv-hdd lv-nvme; do
|
||||
cns=$(j pv $pv '{.spec.claimRef.namespace}'); cn=$(j pv $pv '{.spec.claimRef.name}')
|
||||
[ -n "$cns" ] && [ -n "$cn" ] || continue
|
||||
for p in $($K -n "$cns" get pod -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.volumes[*].persistentVolumeClaim.claimName}{"\n"}{end}' 2>/dev/null | awk -v c="$cn" '{for(i=2;i<=NF;i++) if($i==c) print $1}'); do
|
||||
$K -n "$cns" delete pod "$p" --grace-period=0 --force >/dev/null 2>&1
|
||||
done
|
||||
$K -n "$cns" delete pvc "$cn" --ignore-not-found --wait=false >/dev/null 2>&1
|
||||
done
|
||||
$K delete pv lv-hdd lv-nvme --ignore-not-found --timeout=90s >/dev/null 2>&1 || true
|
||||
$K delete pv lv-hdd lv-nvme --ignore-not-found >/dev/null
|
||||
for pair in "lv-hdd:$W1:hdd:2Gi" "lv-nvme:$W2:nvme:1Gi"; do IFS=: read name node kind size <<<"$pair"
|
||||
nx "$node" "mkdir -p /mnt/$name"
|
||||
$K apply -f - >/dev/null <<EOF
|
||||
@ -636,7 +537,7 @@ done
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 59, "domain": "STOR", "difficulty": "hard", "title": "Migrate data between claims",
|
||||
"id": 116, "domain": "STOR", "difficulty": "hard", "title": "Migrate data between claims",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "mig"), "n": r.choice([7, 11, 13])},
|
||||
"task": CTX + """
|
||||
In `%{ns}`, PVC `old-data` holds files a running pod `legacy` wrote (`/data`). Storage is being
|
||||
@ -665,17 +566,17 @@ spec:
|
||||
EOF
|
||||
for i in $(seq 1 30); do [ "$(j -n %{ns} pod legacy '{.status.phase}')" = Running ] && break; sleep 2; done
|
||||
pv=$(j -n %{ns} pvc old-data '{.spec.volumeName}'); [ -n "$pv" ] && $K patch pv $pv -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' >/dev/null
|
||||
echo "$pv" > "$WS/.q59-pv"
|
||||
echo "$pv" > "$WS/.q116-pv"
|
||||
""",
|
||||
"checks": [
|
||||
C("Job migrate completed", 2, r"""eq "$(j -n %{ns} job migrate '{.status.succeeded}')" 1"""),
|
||||
C("legacy Running on new-data with all %{n} files intact", 3, r"""eq "$(j -n %{ns} pod legacy '{.status.phase}')" Running && eq "$(j -n %{ns} pod legacy '{.spec.volumes[?(@.name!="kube-api-access")].persistentVolumeClaim.claimName}' | tr ' ' '\n' | grep -v '^$' | head -1)" new-data && eq "$($K -n %{ns} exec legacy -- sh -c 'ls /data/file-*.txt | wc -l' 2>/dev/null | tr -d ' ')" %{n} && eq "$($K -n %{ns} exec legacy -- cat /data/file-%{n}.txt 2>/dev/null)" record-%{n}"""),
|
||||
C("old-data deleted; its PV Released (not deleted)", 2, r"""! $K -n %{ns} get pvc old-data >/dev/null 2>&1 && pv=$(cat "$WS/.q59-pv" 2>/dev/null) && eq "$(j pv $pv '{.status.phase}')" Released"""),
|
||||
C("old-data deleted; its PV Released (not deleted)", 2, r"""! $K -n %{ns} get pvc old-data >/dev/null 2>&1 && pv=$(cat "$WS/.q116-pv" 2>/dev/null) && eq "$(j pv $pv '{.status.phase}')" Released"""),
|
||||
],
|
||||
},
|
||||
# ============================================================== TSHOOT ====
|
||||
{
|
||||
"id": 60, "domain": "TSHOOT", "breaks": "w2-kubelet", "difficulty": "hard", "title": "Worker node: three things wrong", "disruptive": True,
|
||||
"id": 117, "domain": "TSHOOT", "breaks": "w2-kubelet", "difficulty": "hard", "title": "Worker node: three things wrong", "disruptive": True,
|
||||
"params": lambda r: {"ctx": "k8s-c1-H"},
|
||||
"task": CTX + """
|
||||
Node `{W2}` is unusable. Make it fully usable again: `Ready`, schedulable, kubelet running and
|
||||
@ -699,7 +600,7 @@ $K -n default delete pod node-probe --wait=false >/dev/null 2>&1; exit $ok"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 61, "domain": "TSHOOT", "breaks": ["scheduler", "controller-manager"], "difficulty": "hard", "title": "Two control-plane components down", "disruptive": True,
|
||||
"id": 118, "domain": "TSHOOT", "breaks": "scheduler", "difficulty": "hard", "title": "Two control-plane components down", "disruptive": True,
|
||||
"params": lambda r: {"ctx": "k8s-c1-H"},
|
||||
"task": CTX + """
|
||||
New Deployments create no pods, and pods that do get created stay `Pending`. Two different
|
||||
@ -723,7 +624,7 @@ $K -n default delete deploy cp-probe --wait=false >/dev/null 2>&1; exit $ok"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 62, "domain": "TSHOOT", "difficulty": "hard", "title": "Application down for four reasons",
|
||||
"id": 119, "domain": "TSHOOT", "difficulty": "hard", "title": "Application down for four reasons",
|
||||
"params": lambda r: {"ctx": ctx_pick(r), "ns": ns_pick(r, "checkout"), "dep": name_pick(r, "checkout")},
|
||||
"task": CTX + """
|
||||
Deployment `%{dep}` in `%{ns}` should have 3 Available replicas and Service `%{dep}` should route
|
||||
@ -767,21 +668,20 @@ spec:
|
||||
selector: {app: %{dep}-web}
|
||||
ports: [{port: 80, targetPort: 8080}]
|
||||
EOF
|
||||
j -n %{ns} deploy %{dep} '{.metadata.uid}' > "$WS/.q62-uid"; j -n %{ns} svc %{dep} '{.spec.clusterIP}' > "$WS/.q62-ip"
|
||||
j -n %{ns} deploy %{dep} '{.metadata.uid}' > "$WS/.q119-uid"; j -n %{ns} svc %{dep} '{.spec.clusterIP}' > "$WS/.q119-ip"
|
||||
""",
|
||||
"checks": [
|
||||
C("3 replicas Available", 3, r"""for i in $(seq 1 6); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 3 ] && exit 0; sleep 5; done; exit 1"""),
|
||||
C("Service routes: endpoints ready, targetPort 80", 2, r"""eq "$(j -n %{ns} svc %{dep} '{.spec.ports[0].targetPort}')" 80 && [ -n "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{dep} '{.items[*].endpoints[?(@.conditions.ready==true)].addresses[*]}')" ]"""),
|
||||
C("Deployment UID and Service ClusterIP unchanged (fixed in place)", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 3 || exit 1 # only once it is actually fixed
|
||||
eq "$(j -n %{ns} deploy %{dep} '{.metadata.uid}')" "$(cat "$WS/.q62-uid")" && eq "$(j -n %{ns} svc %{dep} '{.spec.clusterIP}')" "$(cat "$WS/.q62-ip")" """),
|
||||
C("Deployment UID and Service ClusterIP unchanged (fixed in place)", 2, r"""eq "$(j -n %{ns} deploy %{dep} '{.metadata.uid}')" "$(cat "$WS/.q119-uid")" && eq "$(j -n %{ns} svc %{dep} '{.spec.clusterIP}')" "$(cat "$WS/.q119-ip")" """),
|
||||
C("quota still present and permits the 3 pods", 1, r"""p=$(j -n %{ns} quota tight '{.spec.hard.pods}'); [ -n "$p" ] && [ "$p" -ge 3 ]"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 63, "domain": "TSHOOT", "difficulty": "hard", "title": "Cluster facts, exactly",
|
||||
"id": 120, "domain": "TSHOOT", "difficulty": "hard", "title": "Cluster facts, exactly",
|
||||
"params": lambda r: {"ctx": "k8s-c1-H", "node": r.choice(["{W1}", "{W2}", "{W3}"])},
|
||||
"task": CTX + """
|
||||
Write `{WS}/q63/facts.txt` with these lines, values taken from the running cluster:
|
||||
Write `{WS}/q120/facts.txt` with these lines, values taken from the running cluster:
|
||||
```
|
||||
pod-cidr=<cluster pod CIDR as configured on kube-controller-manager>
|
||||
service-cidr=<service CIDR as configured on kube-apiserver>
|
||||
@ -790,15 +690,15 @@ proxy-mode=<kube-proxy mode from its ConfigMap (empty means iptables — write i
|
||||
%{node}-pod-cidr=<.spec.podCIDR of that node>
|
||||
```
|
||||
""",
|
||||
"setup": r"""rm -rf "$WS/q63"; mkdir -p "$WS/q63" """,
|
||||
"setup": r"""rm -rf "$WS/q120"; mkdir -p "$WS/q120" """,
|
||||
"checks": [
|
||||
C("pod-cidr and service-cidr", 2, r"""f="$WS/q63/facts.txt"; pc=$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'cluster-cidr=[^"]*' | cut -d= -f2); sc=$(j -n kube-system pod -l component=kube-apiserver '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'service-cluster-ip-range=[^"]*' | cut -d= -f2); grep -qx "pod-cidr=$pc" "$f" 2>/dev/null && grep -qx "service-cidr=$sc" "$f" """),
|
||||
C("dns-ip and proxy-mode", 2, r"""f="$WS/q63/facts.txt"; d=$(j -n kube-system svc kube-dns '{.spec.clusterIP}'); m=$(j -n kube-system cm kube-proxy '{.data.config\.conf}' | grep '^mode:' | awk '{print $2}' | tr -d '"'); [ -n "$m" ] || m=iptables; grep -qx "dns-ip=$d" "$f" 2>/dev/null && grep -qx "proxy-mode=$m" "$f" """),
|
||||
C("%{node} pod CIDR", 1, r"""grep -qx "%{node}-pod-cidr=$(j node %{node} '{.spec.podCIDR}')" "$WS/q63/facts.txt" 2>/dev/null"""),
|
||||
C("pod-cidr and service-cidr", 2, r"""f="$WS/q120/facts.txt"; pc=$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'cluster-cidr=[^"]*' | cut -d= -f2); sc=$(j -n kube-system pod -l component=kube-apiserver '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'service-cluster-ip-range=[^"]*' | cut -d= -f2); grep -qx "pod-cidr=$pc" "$f" 2>/dev/null && grep -qx "service-cidr=$sc" "$f" """),
|
||||
C("dns-ip and proxy-mode", 2, r"""f="$WS/q120/facts.txt"; d=$(j -n kube-system svc kube-dns '{.spec.clusterIP}'); m=$(j -n kube-system cm kube-proxy '{.data.config\.conf}' | grep '^mode:' | awk '{print $2}' | tr -d '"'); [ -n "$m" ] || m=iptables; grep -qx "dns-ip=$d" "$f" 2>/dev/null && grep -qx "proxy-mode=$m" "$f" """),
|
||||
C("%{node} pod CIDR", 1, r"""grep -qx "%{node}-pod-cidr=$(j node %{node} '{.spec.podCIDR}')" "$WS/q120/facts.txt" 2>/dev/null"""),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 66, "domain": "TSHOOT", "breaks": "apiserver", "difficulty": "hard", "title": "Nothing works: kube-apiserver itself is down", "disruptive": True,
|
||||
"id": 123, "domain": "TSHOOT", "breaks": "apiserver", "difficulty": "hard", "title": "Nothing works: kube-apiserver itself is down", "disruptive": True,
|
||||
"params": lambda r: {"ctx": "k8s-c1-H"},
|
||||
"task": CTX + """
|
||||
Every `kubectl` command from every context times out or refuses to connect. This is not a
|
||||
@ -829,6 +729,6 @@ $K -n default delete pod api-probe --wait=false >/dev/null 2>&1; exit $ok"""),
|
||||
|
||||
# derived params: w2 = 100 - w for the canary split
|
||||
for _q in QUESTIONS:
|
||||
if _q["id"] == 56:
|
||||
if _q["id"] == 113:
|
||||
_orig = _q["params"]
|
||||
_q["params"] = lambda r, _o=_orig: (lambda p: {**p, "w2": 100 - p["w"]})(_o(r))
|
||||
|
||||
1048
tracks/cka/bank_sim_a.py
Normal file
1048
tracks/cka/bank_sim_a.py
Normal file
File diff suppressed because it is too large
Load Diff
973
tracks/cka/bank_sim_b.py
Normal file
973
tracks/cka/bank_sim_b.py
Normal file
@ -0,0 +1,973 @@
|
||||
"""Simulator Set B - 17 tasks, fixed set, killer.sh-grade difficulty.
|
||||
|
||||
Original scenarios. Weighted toward node-level and control-plane work: etcd flags, kube-proxy
|
||||
rules, ServiceCIDR, bound tokens, PDB-blocked drains, PV reclaim, upgrade planning, CSR signing.
|
||||
|
||||
Run with: ./exam sim b
|
||||
"""
|
||||
from tracks.cka.common import C, NS
|
||||
|
||||
Q = []
|
||||
|
||||
|
||||
def task(**kw):
|
||||
kw.setdefault("difficulty", "hard")
|
||||
kw["set"] = "b"
|
||||
Q.append(kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 301 ----
|
||||
task(
|
||||
id=301, domain="ARCH", title="etcd configuration facts",
|
||||
task="""
|
||||
The security team wants the following about the etcd running on the control-plane node.
|
||||
Write `{WS}/q301/etcd-info.txt` with exactly these five lines (values after the colon):
|
||||
|
||||
```
|
||||
data-dir: <etcd data directory>
|
||||
server-key: <path to the server private key>
|
||||
trusted-ca: <path to the CA used for client certificate verification>
|
||||
client-cert-auth: <yes|no>
|
||||
server-cert-expiry: <notAfter value of the etcd server certificate>
|
||||
```
|
||||
|
||||
Take every value from how etcd is actually started, not from memory.
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q301"; mkdir -p "$WS/q301" """,
|
||||
checks=[
|
||||
C("data-dir and server-key are correct", 3, r"""
|
||||
f="$WS/q301/etcd-info.txt"; [ -f "$f" ] || exit 1
|
||||
cmd=$(j -n kube-system pod -l component=etcd '{.items[0].spec.containers[0].command}')
|
||||
dd=$(echo "$cmd" | tr ',' '\n' | grep -o 'data-dir=[^"]*' | cut -d= -f2)
|
||||
sk=$(echo "$cmd" | tr ',' '\n' | grep -o '\-\-key-file=[^"]*' | cut -d= -f2)
|
||||
grep -q "data-dir: *$dd" "$f" && grep -q "server-key: *$sk" "$f" """),
|
||||
C("trusted-ca and client-cert-auth are correct", 3, r"""
|
||||
f="$WS/q301/etcd-info.txt"
|
||||
cmd=$(j -n kube-system pod -l component=etcd '{.items[0].spec.containers[0].command}')
|
||||
ca=$(echo "$cmd" | tr ',' '\n' | grep -o '\-\-trusted-ca-file=[^"]*' | cut -d= -f2)
|
||||
grep -q "trusted-ca: *$ca" "$f" && grep -Eqi 'client-cert-auth: *(yes|true)' "$f" """),
|
||||
C("server-cert-expiry matches the certificate on disk", 3, r"""
|
||||
exp=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/etcd/server.crt -noout -enddate' | sed 's/notAfter=//')
|
||||
yr=$(echo "$exp" | awk '{print $4}'); mon=$(echo "$exp" | awk '{print $1}'); day=$(echo "$exp" | awk '{print $2}')
|
||||
f="$WS/q301/etcd-info.txt"; grep -q "$yr" "$f" 2>/dev/null && grep -qi "$mon" "$f" && grep -q "$day" "$f" """),
|
||||
],
|
||||
solution="""
|
||||
{SHELL_CP}
|
||||
cat /etc/kubernetes/manifests/etcd.yaml # every flag etcd runs with
|
||||
# --data-dir=/var/lib/etcd
|
||||
# --key-file=/etc/kubernetes/pki/etcd/server.key
|
||||
# --trusted-ca-file=/etc/kubernetes/pki/etcd/ca.crt
|
||||
# --client-cert-auth=true
|
||||
openssl x509 -in /etc/kubernetes/pki/etcd/server.crt -noout -enddate
|
||||
# etcd runs as a static pod, so the manifest in /etc/kubernetes/manifests is the source of truth.
|
||||
# k -n kube-system get pod -l component=etcd -o yaml would show the same command.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 302 ----
|
||||
task(
|
||||
id=302, domain="NET", title="Service rules on the node", breaks="proxy-rules",
|
||||
task="""
|
||||
Confirm that kube-proxy is programming this cluster.
|
||||
|
||||
1. In Namespace `project-proxy` create Pod `proxy-pod` (image `nginx:1-alpine`) and expose it
|
||||
internally as Service `proxy-svc` on port **4000** targeting container port 80.
|
||||
2. From the node where the Pod runs, capture the proxy rules that mention `proxy-svc` into
|
||||
`{WS}/q302/rules.txt` (the node uses iptables; `iptables-save` is available as root).
|
||||
3. Write the Service's ClusterIP to `{WS}/q302/clusterip.txt`.
|
||||
4. Write the proxy mode kube-proxy is running in to `{WS}/q302/mode.txt`
|
||||
(an empty `mode` in its ConfigMap means `iptables`).
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-proxy
|
||||
rm -rf "$WS/q302"; mkdir -p "$WS/q302"
|
||||
$K -n project-proxy delete pod proxy-pod --ignore-not-found --wait=false >/dev/null
|
||||
$K -n project-proxy delete svc proxy-svc --ignore-not-found >/dev/null
|
||||
""",
|
||||
checks=[
|
||||
C("Pod and Service exist, 4000 -> 80, with endpoints", 3, r"""
|
||||
eq "$(j -n project-proxy pod proxy-pod '{.status.phase}')" Running \
|
||||
&& eq "$(j -n project-proxy svc proxy-svc '{.spec.ports[0].port}')" 4000 && eq "$(j -n project-proxy svc proxy-svc '{.spec.ports[0].targetPort}')" 80 \
|
||||
&& [ -n "$(j -n project-proxy endpointslices -l kubernetes.io/service-name=proxy-svc '{.items[*].endpoints[*].addresses[*]}')" ]"""),
|
||||
C("clusterip.txt matches the Service", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q302/clusterip.txt" 2>/dev/null)" "$(j -n project-proxy svc proxy-svc '{.spec.clusterIP}')" """),
|
||||
C("rules.txt contains real proxy rules naming the Service and its ClusterIP", 4, r"""
|
||||
f="$WS/q302/rules.txt"; grep -q 'proxy-svc' "$f" 2>/dev/null && grep -q "$(j -n project-proxy svc proxy-svc '{.spec.clusterIP}')" "$f" && grep -Eq 'KUBE-(SVC|SEP|SERVICES)' "$f" """),
|
||||
C("mode.txt says iptables", 2, r"""grep -qi 'iptables' "$WS/q302/mode.txt" 2>/dev/null"""),
|
||||
],
|
||||
solution="""
|
||||
k -n project-proxy run proxy-pod --image=nginx:1-alpine
|
||||
k -n project-proxy expose pod proxy-pod --name proxy-svc --port 4000 --target-port 80
|
||||
k -n project-proxy get svc proxy-svc -o jsonpath='{.spec.clusterIP}' > {WS}/q302/clusterip.txt
|
||||
k -n project-proxy get pod proxy-pod -o wide # note the node
|
||||
{SHELL_W1} # the node it landed on
|
||||
iptables-save | grep proxy-svc # -> copy into {WS}/q302/rules.txt
|
||||
exit
|
||||
k -n kube-system get cm kube-proxy -o yaml | grep -E '^ *mode:' # empty -> iptables
|
||||
echo iptables > {WS}/q302/mode.txt
|
||||
# Deleting the Service makes the rules disappear within a second - kube-proxy watches the API
|
||||
# and rewrites the rules on every Service/EndpointSlice change.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 303 ----
|
||||
task(
|
||||
id=303, domain="NET", title="A second Service CIDR",
|
||||
task="""
|
||||
The cluster is running out of Service IPs.
|
||||
|
||||
1. Write the CIDR the cluster currently allocates Service IPs from to `{WS}/q303/current-cidr.txt`.
|
||||
2. Add a **second** range `10.180.0.0/16` to the cluster by creating a `ServiceCIDR` object named
|
||||
`extra-range` (do not remove the existing one).
|
||||
3. In Namespace `default` create Deployment `cidr-demo` (image `nginx:1-alpine`) and a ClusterIP
|
||||
Service `cidr-demo` on port 80 whose ClusterIP is taken **from the new range**
|
||||
(request the address explicitly if you need to).
|
||||
4. Write that Service's ClusterIP to `{WS}/q303/new-ip.txt`.
|
||||
""",
|
||||
setup=r"""
|
||||
rm -rf "$WS/q303"; mkdir -p "$WS/q303"
|
||||
$K delete svc cidr-demo --ignore-not-found >/dev/null 2>&1
|
||||
$K delete deploy cidr-demo --ignore-not-found >/dev/null 2>&1
|
||||
$K delete servicecidr extra-range --ignore-not-found >/dev/null 2>&1
|
||||
$K get servicecidr >/dev/null 2>&1 || echo "warn: this cluster has no ServiceCIDR API (needs MultiCIDRServiceAllocator)"
|
||||
""",
|
||||
checks=[
|
||||
C("current-cidr.txt matches the apiserver's service range", 2, r"""
|
||||
sc=$(j -n kube-system pod -l component=kube-apiserver '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'service-cluster-ip-range=[^"]*' | cut -d= -f2)
|
||||
grep -qF "$sc" "$WS/q303/current-cidr.txt" 2>/dev/null"""),
|
||||
C("ServiceCIDR extra-range covers 10.180.0.0/16 and is Ready", 4, r"""
|
||||
j servicecidr extra-range '{.spec.cidrs[*]}' | grep -q '10.180.0.0/16' || exit 1
|
||||
for i in $(seq 1 10); do [ "$(j servicecidr extra-range '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && exit 0; sleep 2; done; exit 1"""),
|
||||
C("Service cidr-demo has an IP from the new range and ready endpoints", 4, r"""
|
||||
ip=$(j -n default svc cidr-demo '{.spec.clusterIP}'); case "$ip" in 10.180.*) ;; *) exit 1;; esac
|
||||
[ -n "$(j -n default endpointslices -l kubernetes.io/service-name=cidr-demo '{.items[*].endpoints[?(@.conditions.ready==true)].addresses[*]}')" ]"""),
|
||||
C("new-ip.txt records that ClusterIP", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q303/new-ip.txt" 2>/dev/null)" "$(j -n default svc cidr-demo '{.spec.clusterIP}')" """),
|
||||
],
|
||||
solution="""
|
||||
k -n kube-system get pod -l component=kube-apiserver -o yaml | grep service-cluster-ip-range
|
||||
# -> e.g. 10.96.0.0/16 ; write it into {WS}/q303/current-cidr.txt
|
||||
k get servicecidr # the built-in "kubernetes" ServiceCIDR
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: ServiceCIDR
|
||||
metadata: {name: extra-range}
|
||||
spec: {cidrs: ["10.180.0.0/16"]}
|
||||
EOF
|
||||
k get servicecidr extra-range -o yaml # wait for Ready=True
|
||||
k create deploy cidr-demo --image=nginx:1-alpine
|
||||
k expose deploy cidr-demo --port=80 --cluster-ip=10.180.0.50
|
||||
k get svc cidr-demo -o jsonpath='{.spec.clusterIP}' > {WS}/q303/new-ip.txt
|
||||
# New ranges are additive: existing Services keep their IPs, and allocation from a new range
|
||||
# happens once the ServiceCIDR is Ready. Requesting --cluster-ip makes it deterministic.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 304 ----
|
||||
task(
|
||||
id=304, domain="ARCH", title="Bound tokens versus legacy tokens",
|
||||
task="""
|
||||
In Namespace `project-token` there is ServiceAccount `reporter`.
|
||||
|
||||
1. Issue a **bound token** for it that is valid for exactly 2 hours and save it to
|
||||
`{WS}/q304/token.jwt`.
|
||||
2. Decode the token's payload and write its `exp` and `iat` claims, as
|
||||
`exp=<value>` and `iat=<value>` on two lines, to `{WS}/q304/claims.txt`.
|
||||
3. Also create a **long-lived** token for the same ServiceAccount, as a Secret named
|
||||
`reporter-legacy` of the correct type, and confirm the controller populated it.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-token
|
||||
rm -rf "$WS/q304"; mkdir -p "$WS/q304"
|
||||
$K -n project-token create sa reporter --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n project-token delete secret reporter-legacy --ignore-not-found >/dev/null
|
||||
""",
|
||||
checks=[
|
||||
C("token.jwt is a JWT issued for the reporter ServiceAccount", 3, r"""
|
||||
f="$WS/q304/token.jwt"; [ -s "$f" ] || exit 1
|
||||
python3 - "$f" <<'PY'
|
||||
import base64,json,sys
|
||||
t=open(sys.argv[1]).read().strip().split(".")
|
||||
if len(t)!=3: sys.exit(1)
|
||||
p=json.loads(base64.urlsafe_b64decode(t[1]+"=="*3))
|
||||
sub=p.get("sub",""); ko=p.get("kubernetes.io",{}).get("serviceaccount",{}).get("name","")
|
||||
sys.exit(0 if "reporter" in sub or ko=="reporter" else 1)
|
||||
PY"""),
|
||||
C("the token's lifetime is 2 hours", 3, r"""
|
||||
python3 - "$WS/q304/token.jwt" <<'PY'
|
||||
import base64,json,sys
|
||||
p=json.loads(base64.urlsafe_b64decode(open(sys.argv[1]).read().strip().split(".")[1]+"=="*3))
|
||||
d=p["exp"]-p["iat"]
|
||||
sys.exit(0 if 7000 <= d <= 7400 else 1)
|
||||
PY"""),
|
||||
C("claims.txt records exp and iat matching the token", 2, r"""
|
||||
python3 - "$WS/q304/token.jwt" "$WS/q304/claims.txt" <<'PY'
|
||||
import base64,json,sys,re
|
||||
p=json.loads(base64.urlsafe_b64decode(open(sys.argv[1]).read().strip().split(".")[1]+"=="*3))
|
||||
try: t=open(sys.argv[2]).read()
|
||||
except OSError: sys.exit(1)
|
||||
ok=re.search(r"exp\s*=\s*%d"%p["exp"],t) and re.search(r"iat\s*=\s*%d"%p["iat"],t)
|
||||
sys.exit(0 if ok else 1)
|
||||
PY"""),
|
||||
C("Secret reporter-legacy is a populated service-account-token for reporter", 3, r"""
|
||||
eq "$(j -n project-token secret reporter-legacy '{.type}')" kubernetes.io/service-account-token \
|
||||
&& eq "$(j -n project-token secret reporter-legacy '{.metadata.annotations.kubernetes\.io/service-account\.name}')" reporter \
|
||||
&& [ -n "$(j -n project-token secret reporter-legacy '{.data.token}')" ]"""),
|
||||
],
|
||||
solution="""
|
||||
k -n project-token create token reporter --duration=2h > {WS}/q304/token.jwt
|
||||
# decode the middle segment (JWTs are base64url, pad it out):
|
||||
cut -d. -f2 {WS}/q304/token.jwt | base64 -d 2>/dev/null | python3 -m json.tool
|
||||
# -> copy exp= and iat= into {WS}/q304/claims.txt
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: reporter-legacy
|
||||
namespace: project-token
|
||||
annotations: {kubernetes.io/service-account.name: reporter}
|
||||
type: kubernetes.io/service-account-token
|
||||
EOF
|
||||
k -n project-token get secret reporter-legacy -o yaml # the controller fills in .data.token
|
||||
# Bound tokens expire and are tied to an audience; the Secret form never expires - avoid it
|
||||
# unless something really needs a static token.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 305 ----
|
||||
task(
|
||||
id=305, domain="WORK", title="Schedule without the scheduler",
|
||||
task="""
|
||||
In Namespace `project-orion`:
|
||||
|
||||
1. Create Pod `direct-assign` (image `busybox:1.36`, command `sleep 86400`) that runs on node
|
||||
`{W3}` **without the scheduler being involved at all**.
|
||||
2. Create Pod `scheduled` (same image and command) that lands on `{W3}` **through** the scheduler.
|
||||
3. Write to `{WS}/q305/proof.txt` the value of the field you used for Pod 1, in the form
|
||||
`field=<name>`, and on a second line `scheduler=<the schedulerName of the second Pod>`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-orion
|
||||
rm -rf "$WS/q305"; mkdir -p "$WS/q305"
|
||||
$K -n project-orion delete pod direct-assign scheduled --ignore-not-found --wait=false >/dev/null
|
||||
$K label node "$W3" tier- >/dev/null 2>&1 || true
|
||||
""",
|
||||
checks=[
|
||||
C("direct-assign Running on the right node with spec.nodeName set", 3, r"""
|
||||
eq "$(j -n project-orion pod direct-assign '{.status.phase}')" Running && eq "$(j -n project-orion pod direct-assign '{.spec.nodeName}')" "$W3" """),
|
||||
C("direct-assign was never scheduled (no Scheduled event from default-scheduler)", 3, r"""
|
||||
! $K -n project-orion get events --field-selector involvedObject.name=direct-assign 2>/dev/null | grep -q 'default-scheduler'"""),
|
||||
C("scheduled Running on the same node via the default scheduler", 3, r"""
|
||||
eq "$(j -n project-orion pod scheduled '{.status.phase}')" Running && eq "$(j -n project-orion pod scheduled '{.spec.nodeName}')" "$W3" \
|
||||
&& $K -n project-orion get events --field-selector involvedObject.name=scheduled 2>/dev/null | grep -q 'default-scheduler'"""),
|
||||
C("proof.txt names nodeName and default-scheduler", 2, r"""
|
||||
grep -q 'field= *nodeName' "$WS/q305/proof.txt" 2>/dev/null && grep -q 'scheduler= *default-scheduler' "$WS/q305/proof.txt" """),
|
||||
],
|
||||
solution="""
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: direct-assign, namespace: project-orion}
|
||||
spec:
|
||||
nodeName: {W3} # bypasses the scheduler entirely - the kubelet just picks it up
|
||||
containers: [{name: c, image: busybox:1.36, command: [sleep, "86400"]}]
|
||||
EOF
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: scheduled, namespace: project-orion}
|
||||
spec:
|
||||
nodeSelector: {kubernetes.io/hostname: {W3}} # the scheduler still makes the decision
|
||||
containers: [{name: c, image: busybox:1.36, command: [sleep, "86400"]}]
|
||||
EOF
|
||||
k -n project-orion get events --field-selector involvedObject.name=direct-assign # no Scheduled event
|
||||
k -n project-orion get events --field-selector involvedObject.name=scheduled # Scheduled by default-scheduler
|
||||
printf 'field=nodeName\\nscheduler=default-scheduler\\n' > {WS}/q305/proof.txt
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 306 ----
|
||||
task(
|
||||
id=306, domain="STOR", title="Grow a volume in place",
|
||||
task="""
|
||||
In Namespace `project-orbit`:
|
||||
|
||||
1. Create StorageClass `expandable` using provisioner `rancher.io/local-path`,
|
||||
`volumeBindingMode: WaitForFirstConsumer`, and allowing volume expansion.
|
||||
2. Create PVC `growing` (1Gi, RWO) using that class and Pod `grower` (busybox:1.36, `sleep 86400`)
|
||||
mounting it at `/data`, so the claim binds.
|
||||
3. Then grow the claim to **2Gi** without deleting it, and write the claim's
|
||||
`.status.capacity.storage` after the resize to `{WS}/q306/capacity.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-orbit
|
||||
rm -rf "$WS/q306"; mkdir -p "$WS/q306"
|
||||
$K -n project-orbit delete pod grower --ignore-not-found --wait=false >/dev/null
|
||||
$K -n project-orbit delete pvc growing --ignore-not-found >/dev/null
|
||||
$K delete sc expandable --ignore-not-found >/dev/null
|
||||
""",
|
||||
checks=[
|
||||
C("StorageClass expandable: local-path, WaitForFirstConsumer, expansion allowed", 3, r"""
|
||||
s="$K get sc expandable"
|
||||
eq "$($s -o jsonpath='{.provisioner}' 2>/dev/null)" rancher.io/local-path && eq "$($s -o jsonpath='{.volumeBindingMode}')" WaitForFirstConsumer \
|
||||
&& eq "$($s -o jsonpath='{.allowVolumeExpansion}')" true"""),
|
||||
C("PVC growing Bound on that class with grower Running", 3, r"""
|
||||
eq "$(j -n project-orbit pvc growing '{.spec.storageClassName}')" expandable && eq "$(j -n project-orbit pvc growing '{.status.phase}')" Bound \
|
||||
&& eq "$(j -n project-orbit pod grower '{.status.phase}')" Running"""),
|
||||
C("claim requests 2Gi and reports at least 2Gi capacity", 4, r"""
|
||||
eq "$(j -n project-orbit pvc growing '{.spec.resources.requests.storage}')" 2Gi || exit 1
|
||||
for i in $(seq 1 12); do c=$(j -n project-orbit pvc growing '{.status.capacity.storage}'); [ "$c" = 2Gi ] && exit 0; sleep 5; done; exit 1"""),
|
||||
C("capacity.txt records the new capacity", 1, r"""grep -q '2Gi' "$WS/q306/capacity.txt" 2>/dev/null"""),
|
||||
],
|
||||
solution="""
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: StorageClass
|
||||
metadata: {name: expandable}
|
||||
provisioner: rancher.io/local-path
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
allowVolumeExpansion: true
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: growing, namespace: project-orbit}
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: expandable
|
||||
resources: {requests: {storage: 1Gi}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: grower, namespace: project-orbit}
|
||||
spec:
|
||||
volumes: [{name: d, persistentVolumeClaim: {claimName: growing}}]
|
||||
containers: [{name: c, image: busybox:1.36, command: [sleep, "86400"], volumeMounts: [{name: d, mountPath: /data}]}]
|
||||
EOF
|
||||
k -n project-orbit patch pvc growing -p '{"spec":{"resources":{"requests":{"storage":"2Gi"}}}}'
|
||||
k -n project-orbit get pvc growing -w # spec 2Gi first, then status.capacity follows
|
||||
k -n project-orbit get pvc growing -o jsonpath='{.status.capacity.storage}' > {WS}/q306/capacity.txt
|
||||
# Shrinking is never allowed. Without allowVolumeExpansion the patch is rejected outright.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 307 ----
|
||||
task(
|
||||
id=307, domain="TSHOOT", title="Node accepts nothing new", breaks="w1-block",
|
||||
task="""
|
||||
Node `{W1}` is `Ready` and its kubelet is healthy, but no new Pod will schedule onto it.
|
||||
|
||||
Find every reason and remove them, so that a Pod with a `nodeSelector` pinning it to `{W1}`
|
||||
starts successfully. Do not delete or recreate the node, and do not use `nodeName` to work around it.
|
||||
""",
|
||||
setup=r"""
|
||||
$K cordon "$W1" >/dev/null 2>&1 || true
|
||||
$K taint node "$W1" maintenance=inprogress:NoSchedule --overwrite >/dev/null 2>&1 || true
|
||||
$K label node "$W1" kubernetes.io/os- >/dev/null 2>&1 || true
|
||||
$K label node "$W1" kubernetes.io/os=linux --overwrite >/dev/null 2>&1 || true
|
||||
""",
|
||||
checks=[
|
||||
C("node is schedulable (uncordoned)", 3, r"""[ "$(j node "$W1" '{.spec.unschedulable}')" != true ]"""),
|
||||
C("the maintenance taint is gone", 3, r"""! j node "$W1" '{.spec.taints}' | grep -q 'maintenance'"""),
|
||||
C("a pinned Pod actually runs there", 4, r"""
|
||||
$K -n default delete pod pin-probe --ignore-not-found --wait=true >/dev/null 2>&1
|
||||
cat <<EOF | $K apply -f - >/dev/null 2>&1
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: pin-probe, namespace: default}
|
||||
spec:
|
||||
nodeSelector: {kubernetes.io/hostname: $W1}
|
||||
containers: [{name: c, image: busybox:1.36, command: [sleep, "120"]}]
|
||||
EOF
|
||||
ok=1; for i in $(seq 1 20); do [ "$(j -n default pod pin-probe '{.status.phase}')" = Running ] && { ok=0; break; }; sleep 3; done
|
||||
$K -n default delete pod pin-probe --wait=false >/dev/null 2>&1; exit $ok"""),
|
||||
],
|
||||
solution="""
|
||||
k describe node {W1} | head -20
|
||||
# Taints: maintenance=inprogress:NoSchedule
|
||||
# Unschedulable: true
|
||||
k uncordon {W1}
|
||||
k taint node {W1} maintenance- # trailing dash removes the taint
|
||||
k get node {W1} -o jsonpath='{.spec.taints}{"\\n"}{.spec.unschedulable}{"\\n"}'
|
||||
# Cordon and taints are two independent mechanisms - `kubectl drain` sets the cordon,
|
||||
# and a taint may have been left by an operator or added by hand. Both must go.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 308 ----
|
||||
task(
|
||||
id=308, domain="ARCH", title="API resource reference card",
|
||||
task="""
|
||||
Write `{WS}/q308/resources.txt` with one line per resource below, in this exact format:
|
||||
|
||||
```
|
||||
<resource>|<shortname or ->|<apiVersion as served>|<namespaced true|false>
|
||||
```
|
||||
|
||||
for these four: `networkpolicies`, `persistentvolumes`, `storageclasses`, `certificatesigningrequests`.
|
||||
|
||||
Take the values from the cluster (`kubectl api-resources`), not from memory. Use `-` when a
|
||||
resource has no shortname. Lines in the order given above.
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q308"; mkdir -p "$WS/q308" """,
|
||||
checks=[
|
||||
C("all four lines present and correctly formatted", 3, r"""
|
||||
f="$WS/q308/resources.txt"; [ -f "$f" ] || exit 1
|
||||
for r in networkpolicies persistentvolumes storageclasses certificatesigningrequests; do grep -q "^$r|" "$f" || exit 1; done
|
||||
[ "$(grep -c '|' "$f")" -ge 4 ]"""),
|
||||
C("shortnames and namespaced flags correct", 4, r"""
|
||||
f="$WS/q308/resources.txt"
|
||||
grep -Eq '^networkpolicies\|netpol\|networking.k8s.io/v1\|true$' "$f" || exit 1
|
||||
grep -Eq '^persistentvolumes\|pv\|v1\|false$' "$f" || exit 1
|
||||
grep -Eq '^storageclasses\|sc\|storage.k8s.io/v1\|false$' "$f" || exit 1
|
||||
grep -Eq '^certificatesigningrequests\|csr\|certificates.k8s.io/v1\|false$' "$f" || exit 1"""),
|
||||
C("order matches the task", 2, r"""
|
||||
f="$WS/q308/resources.txt"
|
||||
[ "$(grep -o '^[a-z]*' "$f" | grep . | head -4 | tr '\n' ' ')" = "networkpolicies persistentvolumes storageclasses certificatesigningrequests " ]"""),
|
||||
],
|
||||
solution="""
|
||||
k api-resources | grep -E '^(networkpolicies|persistentvolumes|storageclasses|certificatesigningrequests) '
|
||||
# NAME SHORTNAMES APIVERSION NAMESPACED KIND
|
||||
cat > {WS}/q308/resources.txt <<'EOF'
|
||||
networkpolicies|netpol|networking.k8s.io/v1|true
|
||||
persistentvolumes|pv|v1|false
|
||||
storageclasses|sc|storage.k8s.io/v1|false
|
||||
certificatesigningrequests|csr|certificates.k8s.io/v1|false
|
||||
EOF
|
||||
# k api-resources --namespaced=false narrows to cluster-scoped resources;
|
||||
# k explain <resource> shows the same apiVersion when you're unsure.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 309 ----
|
||||
task(
|
||||
id=309, domain="WORK", title="A PodDisruptionBudget that blocks maintenance", breaks="w3-drain",
|
||||
task="""
|
||||
Namespace `project-atlas` runs Deployment `atlas-api` with 2 replicas, and a PodDisruptionBudget
|
||||
`atlas-api` that currently makes voluntary eviction impossible.
|
||||
|
||||
Node `{W3}` must be drained for maintenance, and `atlas-api` must keep at least one Pod serving
|
||||
at all times.
|
||||
|
||||
1. Adjust the PodDisruptionBudget so the drain can complete while still guaranteeing one
|
||||
available Pod. Keep the PDB — do not delete it.
|
||||
2. Drain `{W3}` (DaemonSet Pods aside), then make it schedulable again.
|
||||
3. Write the value of the PDB's `.status.disruptionsAllowed` after your change to
|
||||
`{WS}/q309/allowed.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-atlas
|
||||
rm -rf "$WS/q309"; mkdir -p "$WS/q309"
|
||||
$K uncordon "$W3" >/dev/null 2>&1 || true
|
||||
$K -n project-atlas delete deploy atlas-api --ignore-not-found >/dev/null
|
||||
$K -n project-atlas delete pdb atlas-api --ignore-not-found >/dev/null
|
||||
$K apply -f - >/dev/null <<'EOF'
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: atlas-api, namespace: project-atlas}
|
||||
spec:
|
||||
replicas: 2
|
||||
selector: {matchLabels: {app: atlas-api}}
|
||||
template:
|
||||
metadata: {labels: {app: atlas-api}}
|
||||
spec:
|
||||
containers: [{name: api, image: nginx:1.27, resources: {requests: {cpu: 10m, memory: 16Mi}}}]
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata: {name: atlas-api, namespace: project-atlas}
|
||||
spec:
|
||||
minAvailable: 2
|
||||
selector: {matchLabels: {app: atlas-api}}
|
||||
EOF
|
||||
$K -n project-atlas rollout status deploy atlas-api --timeout=120s >/dev/null 2>&1
|
||||
""",
|
||||
checks=[
|
||||
C("PDB still exists and now permits exactly one disruption", 3, r"""
|
||||
p="$K -n project-atlas get pdb atlas-api"
|
||||
$p >/dev/null 2>&1 || exit 1
|
||||
ma=$($p -o jsonpath='{.spec.minAvailable}'); mu=$($p -o jsonpath='{.spec.maxUnavailable}')
|
||||
{ [ "$ma" = 1 ] || [ "$mu" = 1 ]; }"""),
|
||||
C("no atlas-api Pod left on the drained node", 3, r"""
|
||||
n=$($K -n project-atlas get pod -l app=atlas-api -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' 2>/dev/null | grep -c "^$W3$")
|
||||
eq "$n" 0"""),
|
||||
C("both replicas Available and node schedulable again", 3, r"""
|
||||
eq "$(j -n project-atlas deploy atlas-api '{.status.availableReplicas}')" 2 && [ "$(j node "$W3" '{.spec.unschedulable}')" != true ]"""),
|
||||
C("allowed.txt records disruptionsAllowed", 1, r"""grep -Eq '^[0-9]+$' <(tr -dc '0-9\n' < "$WS/q309/allowed.txt" 2>/dev/null | grep .) """),
|
||||
],
|
||||
solution="""
|
||||
k -n project-atlas get pdb atlas-api
|
||||
# ALLOWED DISRUPTIONS: 0 -> minAvailable 2 with 2 replicas means nothing may be evicted;
|
||||
# a drain would hang forever waiting for the eviction API.
|
||||
k -n project-atlas patch pdb atlas-api -p '{"spec":{"minAvailable":1}}'
|
||||
k -n project-atlas get pdb atlas-api # ALLOWED DISRUPTIONS: 1
|
||||
k drain {W3} --ignore-daemonsets --delete-emptydir-data
|
||||
k -n project-atlas rollout status deploy atlas-api
|
||||
k uncordon {W3}
|
||||
k -n project-atlas get pdb atlas-api -o jsonpath='{.status.disruptionsAllowed}' > {WS}/q309/allowed.txt
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 310 ----
|
||||
task(
|
||||
id=310, domain="NET", title="Route to something outside the cluster",
|
||||
task="""
|
||||
An external database lives at `10.190.7.25` port 5432. Applications should reach it through a
|
||||
normal in-cluster Service name.
|
||||
|
||||
In Namespace `project-orbit`:
|
||||
1. Create a Service `external-db` (ClusterIP, port 5432) that has **no selector**.
|
||||
2. Make it resolve to that external address by creating the matching `EndpointSlice`
|
||||
named `external-db-1` (port 5432, TCP, addressType IPv4) with the right labels so the
|
||||
Service adopts it.
|
||||
3. Write the ClusterIP of the Service to `{WS}/q310/ip.txt`.
|
||||
|
||||
Do not use an ExternalName Service.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-orbit
|
||||
rm -rf "$WS/q310"; mkdir -p "$WS/q310"
|
||||
$K -n project-orbit delete svc external-db --ignore-not-found >/dev/null
|
||||
$K -n project-orbit delete endpointslice external-db-1 --ignore-not-found >/dev/null 2>&1
|
||||
""",
|
||||
checks=[
|
||||
C("Service external-db is ClusterIP on 5432 with no selector", 3, r"""
|
||||
s="$K -n project-orbit get svc external-db"
|
||||
eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" ClusterIP && eq "$($s -o jsonpath='{.spec.ports[0].port}')" 5432 && [ -z "$($s -o jsonpath='{.spec.selector}')" ]"""),
|
||||
C("EndpointSlice external-db-1 points at 10.190.7.25:5432", 4, r"""
|
||||
e="$K -n project-orbit get endpointslice external-db-1"
|
||||
eq "$($e -o jsonpath='{.addressType}' 2>/dev/null)" IPv4 && $e -o jsonpath='{.endpoints[*].addresses[*]}' | grep -q '10.190.7.25' \
|
||||
&& eq "$($e -o jsonpath='{.ports[0].port}')" 5432"""),
|
||||
C("the slice is linked to the Service by label", 2, r"""eq "$(j -n project-orbit endpointslice external-db-1 '{.metadata.labels.kubernetes\.io/service-name}')" external-db"""),
|
||||
C("ip.txt records the Service ClusterIP", 1, r"""eq "$(tr -d '[:space:]' < "$WS/q310/ip.txt" 2>/dev/null)" "$(j -n project-orbit svc external-db '{.spec.clusterIP}')" """),
|
||||
],
|
||||
solution="""
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: external-db, namespace: project-orbit}
|
||||
spec:
|
||||
ports: [{name: pg, port: 5432, protocol: TCP, targetPort: 5432}]
|
||||
# no selector: nothing populates the endpoints automatically, so we do it ourselves
|
||||
---
|
||||
apiVersion: discovery.k8s.io/v1
|
||||
kind: EndpointSlice
|
||||
metadata:
|
||||
name: external-db-1
|
||||
namespace: project-orbit
|
||||
labels: {kubernetes.io/service-name: external-db} # this label is the link to the Service
|
||||
addressType: IPv4
|
||||
ports: [{name: pg, port: 5432, protocol: TCP}]
|
||||
endpoints:
|
||||
- addresses: ["10.190.7.25"]
|
||||
conditions: {ready: true}
|
||||
EOF
|
||||
k -n project-orbit get svc external-db -o jsonpath='{.spec.clusterIP}' > {WS}/q310/ip.txt
|
||||
k -n project-orbit get endpointslice
|
||||
# ExternalName would return a CNAME instead and gives you no ClusterIP or port mapping.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 311 ----
|
||||
task(
|
||||
id=311, domain="ARCH", title="Image inventory across the cluster",
|
||||
task="""
|
||||
Produce `{WS}/q311/images.txt`: for every Deployment in every Namespace, one line
|
||||
|
||||
```
|
||||
<namespace>/<deployment> <image>[,<image>...]
|
||||
```
|
||||
|
||||
with the images of its Pod template (init containers included), comma-separated in the order they
|
||||
appear, and the file sorted by the first column.
|
||||
|
||||
Then write the number of **distinct** images in use across those Deployments to
|
||||
`{WS}/q311/distinct.txt`.
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q311"; mkdir -p "$WS/q311" """,
|
||||
checks=[
|
||||
C("images.txt matches the cluster exactly", 5, r"""
|
||||
want=$($K get deploy -A -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
out=[]
|
||||
for d in json.load(sys.stdin)["items"]:
|
||||
s=d["spec"]["template"]["spec"]
|
||||
imgs=[c["image"] for c in s.get("initContainers",[])]+[c["image"] for c in s["containers"]]
|
||||
out.append("%s/%s %s"%(d["metadata"]["namespace"],d["metadata"]["name"],",".join(imgs)))
|
||||
print("\n".join(sorted(out)))')
|
||||
[ "$(grep . "$WS/q311/images.txt" 2>/dev/null | sort)" = "$want" ]"""),
|
||||
C("distinct.txt has the right count", 3, r"""
|
||||
want=$($K get deploy -A -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
s=set()
|
||||
for d in json.load(sys.stdin)["items"]:
|
||||
sp=d["spec"]["template"]["spec"]
|
||||
for c in sp.get("initContainers",[])+sp["containers"]: s.add(c["image"])
|
||||
print(len(s))')
|
||||
eq "$(tr -dc '0-9' < "$WS/q311/distinct.txt" 2>/dev/null)" "$want" """),
|
||||
],
|
||||
solution="""
|
||||
k get deploy -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{" "}{range .spec.template.spec.initContainers[*]}{.image}{","}{end}{range .spec.template.spec.containers[*]}{.image}{","}{end}{"\\n"}{end}' \\
|
||||
| sed 's/,$//' | sort > {WS}/q311/images.txt
|
||||
k get deploy -A -o jsonpath='{range .items[*]}{range .spec.template.spec.initContainers[*]}{.image}{"\\n"}{end}{range .spec.template.spec.containers[*]}{.image}{"\\n"}{end}{end}' \\
|
||||
| grep . | sort -u | wc -l > {WS}/q311/distinct.txt
|
||||
# Two `range` blocks over the same pod spec keeps init and normal containers in order.
|
||||
# A trailing comma is easiest to strip afterwards with sed.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 312 ----
|
||||
task(
|
||||
id=312, domain="WORK", title="A Job that fails, then one that doesn't",
|
||||
task="""
|
||||
In Namespace `project-orion`:
|
||||
|
||||
1. Job `flaky` exists and keeps failing. Let it reach its terminal state, then write the name of
|
||||
the condition that marks it failed and its reason, as `<Type>/<Reason>`, to `{WS}/q312/why.txt`.
|
||||
2. Create a **new** Job `steady` that does the same work correctly: image `busybox:1.36`,
|
||||
6 completions, 3 in parallel, `backoffLimit: 2`, restartPolicy Never, each Pod printing
|
||||
`ok` and exiting 0.
|
||||
3. `steady` must complete all 6.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-orion
|
||||
rm -rf "$WS/q312"; mkdir -p "$WS/q312"
|
||||
$K -n project-orion delete job flaky steady --ignore-not-found >/dev/null
|
||||
$K apply -f - >/dev/null <<'EOF'
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata: {name: flaky, namespace: project-orion}
|
||||
spec:
|
||||
completions: 2
|
||||
parallelism: 1
|
||||
backoffLimit: 1
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers: [{name: c, image: busybox:1.36, command: [sh, -c, "echo starting; exit 7"]}]
|
||||
EOF
|
||||
""",
|
||||
checks=[
|
||||
C("why.txt names the Failed condition and BackoffLimitExceeded", 3, r"""
|
||||
f="$WS/q312/why.txt"; grep -qi 'Failed' "$f" 2>/dev/null && grep -qi 'BackoffLimitExceeded' "$f" """),
|
||||
C("Job steady: 6 completions, parallelism 3, backoffLimit 2, restartPolicy Never", 3, r"""
|
||||
j1="$K -n project-orion get job steady"
|
||||
eq "$($j1 -o jsonpath='{.spec.completions}' 2>/dev/null)" 6 && eq "$($j1 -o jsonpath='{.spec.parallelism}')" 3 \
|
||||
&& eq "$($j1 -o jsonpath='{.spec.backoffLimit}')" 2 && eq "$($j1 -o jsonpath='{.spec.template.spec.restartPolicy}')" Never"""),
|
||||
C("steady completed all 6", 4, r"""for i in $(seq 1 20); do [ "$(j -n project-orion job steady '{.status.succeeded}')" = 6 ] && exit 0; sleep 3; done; exit 1"""),
|
||||
],
|
||||
solution="""
|
||||
k -n project-orion get job flaky -o yaml | grep -A6 conditions
|
||||
# type: Failed / reason: BackoffLimitExceeded
|
||||
k -n project-orion describe job flaky
|
||||
k -n project-orion logs job/flaky # exit 7
|
||||
echo Failed/BackoffLimitExceeded > {WS}/q312/why.txt
|
||||
k -n project-orion create job steady --image=busybox:1.36 $do -- sh -c "echo ok" > steady.yaml
|
||||
# set spec.completions: 6, spec.parallelism: 3, spec.backoffLimit: 2
|
||||
k apply -f steady.yaml
|
||||
k -n project-orion wait --for=condition=complete job/steady --timeout=120s
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 313 ----
|
||||
task(
|
||||
id=313, domain="NET", title="Two hosts, TLS, and a default backend",
|
||||
task="""
|
||||
Namespace `project-r700` has Services `web-classic` and `web-next` (port 80).
|
||||
|
||||
Create Ingress `multi` in that Namespace with class `nginx`:
|
||||
|
||||
- host `classic.r700.io`, path `/` (Prefix) → `web-classic:80`, TLS with secret `classic-tls`
|
||||
- host `next.r700.io`, path `/` (Prefix) → `web-next:80`, TLS with the same secret
|
||||
- a **default backend** (requests matching no rule) → `web-classic:80`
|
||||
|
||||
Also create the TLS secret `classic-tls` from a self-signed certificate whose CN is `classic.r700.io`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-r700
|
||||
for s in web-classic web-next; do
|
||||
$K -n project-r700 create deploy $s --image=nginx:1.27 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n project-r700 expose deploy $s --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
done
|
||||
$K -n project-r700 delete ingress multi --ignore-not-found >/dev/null
|
||||
$K -n project-r700 delete secret classic-tls --ignore-not-found >/dev/null
|
||||
""",
|
||||
checks=[
|
||||
C("Secret classic-tls is a TLS secret with CN classic.r700.io", 3, r"""
|
||||
eq "$(j -n project-r700 secret classic-tls '{.type}')" kubernetes.io/tls \
|
||||
&& j -n project-r700 secret classic-tls '{.data.tls\.crt}' | base64 -d | openssl x509 -noout -subject 2>/dev/null | grep -q 'CN *= *classic.r700.io'"""),
|
||||
C("both host rules route to the right Services on /", 3, r"""
|
||||
$K -n project-r700 get ingress multi -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
rs=json.load(sys.stdin)["spec"]["rules"]
|
||||
m={r["host"]:r["http"]["paths"][0] for r in rs if r.get("host")}
|
||||
ok=all(h in m and m[h]["pathType"]=="Prefix" and m[h]["path"]=="/" for h in ("classic.r700.io","next.r700.io"))
|
||||
ok=ok and m["classic.r700.io"]["backend"]["service"]["name"]=="web-classic" and m["next.r700.io"]["backend"]["service"]["name"]=="web-next"
|
||||
sys.exit(0 if ok else 1)'"""),
|
||||
C("TLS covers both hosts with classic-tls", 2, r"""
|
||||
$K -n project-r700 get ingress multi -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
t=json.load(sys.stdin)["spec"].get("tls",[])
|
||||
hosts={h for e in t for h in e.get("hosts",[])}; secs={e.get("secretName") for e in t}
|
||||
sys.exit(0 if {"classic.r700.io","next.r700.io"}<=hosts and secs=={"classic-tls"} else 1)'"""),
|
||||
C("defaultBackend points at web-classic:80, class is nginx", 3, r"""
|
||||
i="$K -n project-r700 get ingress multi"
|
||||
eq "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" nginx \
|
||||
&& eq "$($i -o jsonpath='{.spec.defaultBackend.service.name}')" web-classic && eq "$($i -o jsonpath='{.spec.defaultBackend.service.port.number}')" 80"""),
|
||||
],
|
||||
solution="""
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=classic.r700.io" -keyout t.key -out t.crt
|
||||
k -n project-r700 create secret tls classic-tls --cert=t.crt --key=t.key
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata: {name: multi, namespace: project-r700}
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
defaultBackend: {service: {name: web-classic, port: {number: 80}}}
|
||||
tls:
|
||||
- hosts: [classic.r700.io, next.r700.io]
|
||||
secretName: classic-tls
|
||||
rules:
|
||||
- host: classic.r700.io
|
||||
http: {paths: [{path: /, pathType: Prefix, backend: {service: {name: web-classic, port: {number: 80}}}}]}
|
||||
- host: next.r700.io
|
||||
http: {paths: [{path: /, pathType: Prefix, backend: {service: {name: web-next, port: {number: 80}}}}]}
|
||||
EOF
|
||||
# `kubectl create ingress` can do most of this:
|
||||
# k -n project-r700 create ingress multi --class=nginx \\
|
||||
# --rule="classic.r700.io/*=web-classic:80,tls=classic-tls" \\
|
||||
# --rule="next.r700.io/*=web-next:80,tls=classic-tls" \\
|
||||
# --default-backend=web-classic:80
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 314 ----
|
||||
task(
|
||||
id=314, domain="TSHOOT", title="A static pod that never starts", breaks="w3-kubelet",
|
||||
task="""
|
||||
Someone added a static Pod manifest on node `{W3}` for a Pod called `edge-cache`, but no such Pod
|
||||
ever appears in the cluster and the node is otherwise healthy.
|
||||
|
||||
Find out why, fix it on the node, and get the mirror Pod `edge-cache-{W3}` Running in Namespace
|
||||
`default` with image `nginx:1.27`.
|
||||
|
||||
Write the one-line reason it wasn't starting to `{WS}/q314/reason.txt`.
|
||||
""",
|
||||
setup=r"""
|
||||
rm -rf "$WS/q314"; mkdir -p "$WS/q314"
|
||||
nx "$W3" 'rm -f /etc/kubernetes/manifests/edge-cache.yaml
|
||||
d=$(grep staticPodPath /var/lib/kubelet/config.yaml | awk "{print \$2}")
|
||||
mkdir -p "$d"
|
||||
cat > "$d/edge-cache.yaml" <<EOF
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: edge-cache
|
||||
namespace: default
|
||||
spec:
|
||||
containers:
|
||||
- name: cache
|
||||
image: nginx:1.27
|
||||
volumeMounts:
|
||||
- name: conf
|
||||
mountPath: /etc/nginx/conf.d
|
||||
volumes:
|
||||
- name: conf
|
||||
hostPath:
|
||||
path: /opt/edge-cache/conf
|
||||
type: Directory
|
||||
EOF
|
||||
rm -rf /opt/edge-cache
|
||||
systemctl restart kubelet'
|
||||
""",
|
||||
checks=[
|
||||
C("mirror pod edge-cache-<node> is Running with nginx:1.27", 5, r"""
|
||||
for i in $(seq 1 12); do
|
||||
[ "$(j -n default pod "edge-cache-$W3" '{.status.phase}')" = Running ] && [ "$(j -n default pod "edge-cache-$W3" '{.spec.containers[0].image}')" = nginx:1.27 ] && exit 0
|
||||
sleep 5
|
||||
done; exit 1"""),
|
||||
C("the hostPath the manifest references now exists on the node", 3, r"""
|
||||
p=$(nx "$W3" 'grep -A2 hostPath /etc/kubernetes/manifests/edge-cache.yaml /etc/kubernetes/static/edge-cache.yaml 2>/dev/null | grep "path:" | head -1' | awk '{print $2}')
|
||||
[ -n "$p" ] && nx "$W3" "test -d $p" """),
|
||||
C("reason.txt mentions the missing host directory", 2, r"""grep -Eqi 'director|hostpath|/opt/edge-cache|not exist|missing' "$WS/q314/reason.txt" 2>/dev/null"""),
|
||||
],
|
||||
solution="""
|
||||
k get pod -A | grep edge-cache # nothing
|
||||
{SHELL_W3}
|
||||
journalctl -u kubelet -n 40 --no-pager | grep -i edge-cache
|
||||
# ... failed to start ... hostPath type check failed: /opt/edge-cache/conf is not a directory
|
||||
grep staticPodPath /var/lib/kubelet/config.yaml
|
||||
cat <staticPodPath>/edge-cache.yaml # hostPath /opt/edge-cache/conf with type: Directory
|
||||
mkdir -p /opt/edge-cache/conf # type: Directory means the kubelet will NOT create it
|
||||
exit
|
||||
k get pod edge-cache-{W3}
|
||||
echo 'hostPath /opt/edge-cache/conf did not exist and type was Directory' > {WS}/q314/reason.txt
|
||||
# The alternative fix is type: DirectoryOrCreate, but the task asks for the pod to run as written.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 315 ----
|
||||
task(
|
||||
id=315, domain="STOR", title="Keep the data when the claim goes",
|
||||
task="""
|
||||
In Namespace `project-orbit`, Pod `keeper` writes to PVC `keeper-claim`, which was provisioned
|
||||
dynamically by the default StorageClass — whose reclaim policy is `Delete`.
|
||||
|
||||
The data must survive the claim being deleted:
|
||||
|
||||
1. Without recreating anything, make sure the bound PersistentVolume is **retained** when its claim
|
||||
goes away.
|
||||
2. Write the PV's name to `{WS}/q315/pv.txt`.
|
||||
3. Delete Pod `keeper` and PVC `keeper-claim`.
|
||||
4. The PV must still exist afterwards, in phase `Released`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns project-orbit
|
||||
rm -rf "$WS/q315"; mkdir -p "$WS/q315"
|
||||
$K -n project-orbit delete pod keeper --ignore-not-found --wait=true >/dev/null
|
||||
$K -n project-orbit delete pvc keeper-claim --ignore-not-found >/dev/null
|
||||
$K apply -f - >/dev/null <<'EOF'
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: keeper-claim, namespace: project-orbit}
|
||||
spec: {accessModes: [ReadWriteOnce], resources: {requests: {storage: 200Mi}}}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata: {name: keeper, namespace: project-orbit}
|
||||
spec:
|
||||
volumes: [{name: d, persistentVolumeClaim: {claimName: keeper-claim}}]
|
||||
containers: [{name: c, image: busybox:1.36, command: [sh, -c, "echo important > /data/keep.txt; sleep 86400"], volumeMounts: [{name: d, mountPath: /data}]}]
|
||||
EOF
|
||||
for i in $(seq 1 30); do [ "$(j -n project-orbit pod keeper '{.status.phase}')" = Running ] && break; sleep 2; done
|
||||
""",
|
||||
checks=[
|
||||
C("pv.txt names the PV that was bound to keeper-claim", 3, r"""
|
||||
p=$(tr -d '[:space:]' < "$WS/q315/pv.txt" 2>/dev/null); [ -n "$p" ] || exit 1
|
||||
eq "$(j pv "$p" '{.spec.claimRef.name}')" keeper-claim"""),
|
||||
C("that PV has reclaim policy Retain", 4, r"""eq "$(j pv "$(tr -d '[:space:]' < "$WS/q315/pv.txt")" '{.spec.persistentVolumeReclaimPolicy}')" Retain"""),
|
||||
C("Pod and PVC are gone", 2, r"""! $K -n project-orbit get pod keeper >/dev/null 2>&1 && ! $K -n project-orbit get pvc keeper-claim >/dev/null 2>&1"""),
|
||||
C("the PV still exists and is Released", 3, r"""
|
||||
p=$(tr -d '[:space:]' < "$WS/q315/pv.txt"); for i in $(seq 1 10); do [ "$(j pv "$p" '{.status.phase}')" = Released ] && exit 0; sleep 3; done; exit 1"""),
|
||||
],
|
||||
solution="""
|
||||
k -n project-orbit get pvc keeper-claim -o jsonpath='{.spec.volumeName}' | tee {WS}/q315/pv.txt
|
||||
PV=$(cat {WS}/q315/pv.txt)
|
||||
k patch pv $PV -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
|
||||
k get pv $PV # RECLAIM POLICY: Retain
|
||||
k -n project-orbit delete pod keeper --now
|
||||
k -n project-orbit delete pvc keeper-claim
|
||||
k get pv $PV # STATUS: Released - data still on disk
|
||||
# Patch the PV, not the StorageClass: the class only sets the policy for volumes created later.
|
||||
# To reuse it, clear .spec.claimRef and it becomes Available again.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 316 ----
|
||||
task(
|
||||
id=316, domain="ARCH", title="Plan a control-plane upgrade",
|
||||
task="""
|
||||
The control-plane node must be upgraded to the next patch release.
|
||||
|
||||
1. Write the cluster's current control-plane version to `{WS}/q316/current.txt` (as `kubectl` reports it).
|
||||
2. Run the kubeadm command that shows what an upgrade would do, and save its output to
|
||||
`{WS}/q316/plan.txt`.
|
||||
3. Write to `{WS}/q316/steps.sh` the ordered commands you would run to upgrade the control-plane
|
||||
node — package install of kubeadm, the kubeadm upgrade step, draining, upgrading kubelet and
|
||||
kubectl, restarting the kubelet, and uncordoning — one command per line, in the order they must
|
||||
be executed. Do **not** execute them.
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q316"; mkdir -p "$WS/q316" """,
|
||||
checks=[
|
||||
C("current.txt matches the server version", 2, r"""
|
||||
v=$($K version -o json 2>/dev/null | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])')
|
||||
grep -qF "$v" "$WS/q316/current.txt" 2>/dev/null"""),
|
||||
C("plan.txt is real kubeadm upgrade plan output", 3, r"""grep -Eqi 'upgrade|component|version' "$WS/q316/plan.txt" 2>/dev/null && [ "$(wc -l < "$WS/q316/plan.txt")" -ge 5 ]"""),
|
||||
C("steps.sh has the commands in the correct order", 5, r"""
|
||||
f="$WS/q316/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 -q 'drain' "$f" && grep -q 'uncordon' "$f" || exit 1
|
||||
grep -Eq '(apt|yum|dnf).*(install).*kubelet' "$f" || exit 1
|
||||
grep -Eq 'systemctl (restart|daemon-reload)' "$f" || exit 1
|
||||
n=$(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)
|
||||
dr=$(grep -n 'drain' "$f" | head -1 | cut -d: -f1)
|
||||
un=$(grep -n 'uncordon' "$f" | head -1 | cut -d: -f1)
|
||||
[ "$ka" -lt "$n" ] && [ "$n" -lt "$kl" ] && [ "$dr" -lt "$kl" ] && [ "$kl" -lt "$un" ]"""),
|
||||
],
|
||||
solution="""
|
||||
k version -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])' > {WS}/q316/current.txt
|
||||
{SHELL_CP}
|
||||
kubeadm upgrade plan | tee /tmp/plan.txt # copy into {WS}/q316/plan.txt
|
||||
cat > {WS}/q316/steps.sh <<'EOF'
|
||||
apt-get update && apt-get install -y kubeadm=1.34.1-1.1
|
||||
kubeadm upgrade plan
|
||||
kubeadm upgrade apply v1.34.1
|
||||
kubectl drain <control-plane> --ignore-daemonsets
|
||||
apt-get install -y kubelet=1.34.1-1.1 kubectl=1.34.1-1.1
|
||||
systemctl daemon-reload
|
||||
systemctl restart kubelet
|
||||
kubectl uncordon <control-plane>
|
||||
EOF
|
||||
# Order matters and is the point of this task: kubeadm binary first, then `kubeadm upgrade apply`
|
||||
# (it upgrades the static pod control plane), then drain, then kubelet/kubectl packages, restart,
|
||||
# uncordon. Workers use `kubeadm upgrade node` instead of `upgrade apply`.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 317 ----
|
||||
task(
|
||||
id=317, domain="TSHOOT", title="Certificates stop being issued", breaks="controller-manager",
|
||||
task="""
|
||||
Certificate signing requests are approved but never get a certificate: `kubectl get csr` shows
|
||||
them `Approved` with an empty `CONDITION`-issued certificate forever.
|
||||
|
||||
Find the responsible control-plane component, repair it on the control-plane node, and prove it
|
||||
works: a CertificateSigningRequest named `issue-probe` (CN `probe`, signer
|
||||
`kubernetes.io/kube-apiserver-client`, usage `client auth`) that you create and approve must
|
||||
receive a certificate.
|
||||
""",
|
||||
setup=r"""
|
||||
$K delete csr issue-probe --ignore-not-found >/dev/null 2>&1
|
||||
nx "$CP" '
|
||||
f=/etc/kubernetes/manifests/kube-controller-manager.yaml
|
||||
grep -q "cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt" $f && sed -i "s|--cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt|--cluster-signing-cert-file=/etc/kubernetes/pki/ca-signing.crt|" $f
|
||||
'
|
||||
""",
|
||||
checks=[
|
||||
C("kube-controller-manager points at a signing certificate that exists", 4, r"""
|
||||
p=$(nx "$CP" 'grep -o "\-\-cluster-signing-cert-file=[^ ]*" /etc/kubernetes/manifests/kube-controller-manager.yaml' | cut -d= -f2)
|
||||
[ -n "$p" ] && nx "$CP" "test -f $p" """),
|
||||
C("kube-controller-manager pod is Running", 3, r"""
|
||||
for i in $(seq 1 12); do [ "$(j -n kube-system pod -l component=kube-controller-manager '{.items[0].status.phase}')" = Running ] && exit 0; sleep 5; done; exit 1"""),
|
||||
C("an approved CSR now receives a certificate", 5, r"""
|
||||
$K get csr issue-probe >/dev/null 2>&1 || exit 1
|
||||
$K get csr issue-probe -o jsonpath='{.status.conditions[*].type}' | grep -q Approved || exit 1
|
||||
for i in $(seq 1 15); do [ -n "$(j csr issue-probe '{.status.certificate}')" ] && exit 0; sleep 4; done; exit 1"""),
|
||||
],
|
||||
solution="""
|
||||
k get csr # Approved but never Issued
|
||||
k -n kube-system logs kube-controller-manager-{CP} | tail -20
|
||||
# ... unable to load client CA file .../ca-signing.crt: no such file or directory
|
||||
{SHELL_CP}
|
||||
grep signing /etc/kubernetes/manifests/kube-controller-manager.yaml
|
||||
ls /etc/kubernetes/pki/ca.crt # the real file
|
||||
vi /etc/kubernetes/manifests/kube-controller-manager.yaml
|
||||
# --cluster-signing-cert-file=/etc/kubernetes/pki/ca.crt
|
||||
exit
|
||||
openssl genrsa -out probe.key 2048
|
||||
openssl req -new -key probe.key -subj "/CN=probe" -out probe.csr
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: certificates.k8s.io/v1
|
||||
kind: CertificateSigningRequest
|
||||
metadata: {name: issue-probe}
|
||||
spec: {request: $(base64 -w0 probe.csr), signerName: kubernetes.io/kube-apiserver-client, usages: [client auth]}
|
||||
EOF
|
||||
k certificate approve issue-probe
|
||||
k get csr issue-probe # certificate now populated
|
||||
# The csrsigning controller lives in kube-controller-manager: approval is the API's job,
|
||||
# but issuing the certificate is the controller's.
|
||||
""",
|
||||
)
|
||||
|
||||
QUESTIONS = Q
|
||||
@ -7,8 +7,8 @@ repair, PSA-free securityContext work, HPA behavior blocks, and "write the answe
|
||||
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.
|
||||
- 410 audits the installed CNI instead of installing Calico over kindnet.
|
||||
- 413 plans a control-plane upgrade instead of running `kubeadm upgrade apply` for real.
|
||||
|
||||
Run with: ./exam sim c
|
||||
"""
|
||||
@ -23,9 +23,9 @@ def task(**kw):
|
||||
Q.append(kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 68 ----
|
||||
# ------------------------------------------------------------------ 401 ----
|
||||
task(
|
||||
id=68, domain="STOR", title="Provision geo-fence persistent storage",
|
||||
id=401, domain="STOR", title="Provision geo-fence persistent storage",
|
||||
task="""
|
||||
In Namespace `geo-fence`:
|
||||
|
||||
@ -100,31 +100,31 @@ k -n geo-fence get pvc
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 69 ----
|
||||
# ------------------------------------------------------------------ 402 ----
|
||||
task(
|
||||
id=69, domain="ARCH", title="Render a chart, then install it",
|
||||
id=402, domain="ARCH", title="Render a chart, then install it",
|
||||
task="""
|
||||
A chart is at `{WS}/q69/chart`.
|
||||
A chart is at `{WS}/q402/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`.
|
||||
`{WS}/q402/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`.
|
||||
3. Save the output of listing the releases in that Namespace to `{WS}/q402/releases.txt`.
|
||||
""",
|
||||
setup=r"""
|
||||
rm -rf "$WS/q69"; mkdir -p "$WS/q69/chart/templates"
|
||||
cat > "$WS/q69/chart/Chart.yaml" <<'EOF'
|
||||
rm -rf "$WS/q402"; mkdir -p "$WS/q402/chart/templates"
|
||||
cat > "$WS/q402/chart/Chart.yaml" <<'EOF'
|
||||
apiVersion: v2
|
||||
name: ride
|
||||
version: 0.2.0
|
||||
EOF
|
||||
cat > "$WS/q69/chart/values.yaml" <<'EOF'
|
||||
cat > "$WS/q402/chart/values.yaml" <<'EOF'
|
||||
replicaCount: 1
|
||||
image: nginx:1.27
|
||||
EOF
|
||||
cat > "$WS/q69/chart/templates/deploy.yaml" <<'EOF'
|
||||
cat > "$WS/q402/chart/templates/deploy.yaml" <<'EOF'
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: {{ .Release.Name }}-server, namespace: {{ .Release.Namespace }}}
|
||||
@ -141,29 +141,29 @@ 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
|
||||
f="$WS/q402/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" """),
|
||||
grep -q 'ride-argocd' "$WS/q402/releases.txt" 2>/dev/null && grep -qi 'deployed' "$WS/q402/releases.txt" """),
|
||||
],
|
||||
solution="""
|
||||
helm template ride-argocd {WS}/q69/chart -n ride-gitops --set replicaCount=3 > {WS}/q69/manifest.yaml
|
||||
helm template ride-argocd {WS}/q402/chart -n ride-gitops --set replicaCount=3 > {WS}/q402/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 -n ride-gitops install ride-argocd {WS}/q402/chart --set replicaCount=3
|
||||
helm -n ride-gitops list > {WS}/q402/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 ----
|
||||
# ------------------------------------------------------------------ 403 ----
|
||||
task(
|
||||
id=70, domain="TSHOOT", title="Fix the driver onboarding app",
|
||||
id=403, domain="TSHOOT", title="Fix the driver onboarding app",
|
||||
task="""
|
||||
Deployment `onboard-app` in Namespace `driver-onboard` never becomes Ready. It has **three**
|
||||
separate faults.
|
||||
@ -202,11 +202,8 @@ i=$(j -n driver-onboard deploy onboard-app '{.spec.template.spec.containers[0].i
|
||||
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("readiness probe uses a path nginx actually serves", 2, r"""
|
||||
eq "$(j -n driver-onboard deploy onboard-app '{.spec.template.spec.containers[0].readinessProbe.httpGet.path}')" /"""),
|
||||
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"""),
|
||||
],
|
||||
@ -225,21 +222,21 @@ k -n driver-onboard rollout status deploy onboard-app
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 71 ----
|
||||
# ------------------------------------------------------------------ 404 ----
|
||||
task(
|
||||
id=71, domain="NET", title="Expose and verify the dispatch API",
|
||||
id=404, 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`.
|
||||
response body to `{WS}/q404/test-result.txt`.
|
||||
3. Write the Service's ClusterIP — the address only, nothing else — to `{WS}/q404/service-ip.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns dispatch-hub
|
||||
rm -rf "$WS/q71"; mkdir -p "$WS/q71"
|
||||
rm -rf "$WS/q404"; mkdir -p "$WS/q404"
|
||||
$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
|
||||
@ -254,26 +251,26 @@ eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" ClusterIP \
|
||||
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"""),
|
||||
grep -qi 'nginx\|<html' "$WS/q404/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)
|
||||
got=$(tr -d '[:space:]' < "$WS/q404/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
|
||||
wget -qO- http://dispatch-service.dispatch-hub.svc:80 > {WS}/q404/test-result.txt
|
||||
k -n dispatch-hub get svc dispatch-service -o jsonpath='{.spec.clusterIP}' > {WS}/q404/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 ----
|
||||
# ------------------------------------------------------------------ 405 ----
|
||||
task(
|
||||
id=72, domain="STOR", title="Migrate ride history to a Retain volume",
|
||||
id=405, 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.
|
||||
@ -407,9 +404,9 @@ k -n ride-history scale deploy ride-archive --replicas=1
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 73 ----
|
||||
# ------------------------------------------------------------------ 406 ----
|
||||
task(
|
||||
id=73, domain="TSHOOT", title="Fix ETA service connectivity",
|
||||
id=406, 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.
|
||||
@ -457,9 +454,9 @@ k -n eta-service get endpointslices -l kubernetes.io/service-name=eta-backend
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 74 ----
|
||||
# ------------------------------------------------------------------ 407 ----
|
||||
task(
|
||||
id=74, domain="NET", title="Fleet dashboard canary with Gateway API",
|
||||
id=407, 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.
|
||||
@ -480,7 +477,6 @@ 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
|
||||
@ -552,9 +548,9 @@ EOF
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 75 ----
|
||||
# ------------------------------------------------------------------ 408 ----
|
||||
task(
|
||||
id=75, domain="ARCH", title="Cluster-wide read access for the auth team",
|
||||
id=408, 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.
|
||||
|
||||
@ -563,11 +559,11 @@ The rider auth team needs a read-only identity that works in **every** Namespace
|
||||
`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`.
|
||||
`{WS}/q408/auth-check.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns rider-auth
|
||||
rm -rf "$WS/q75"; mkdir -p "$WS/q75"
|
||||
rm -rf "$WS/q408"; mkdir -p "$WS/q408"
|
||||
$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
|
||||
@ -588,22 +584,22 @@ 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"""),
|
||||
grep -qx 'yes' "$WS/q408/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
|
||||
k auth can-i list pods -n kube-system --as=system:serviceaccount:rider-auth:auth-auditor > {WS}/q408/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 ----
|
||||
# ------------------------------------------------------------------ 409 ----
|
||||
task(
|
||||
id=76, domain="WORK", title="Add an init container to the trip logger",
|
||||
id=409, 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.
|
||||
|
||||
@ -641,7 +637,7 @@ eq "$(j -n trip-tracker deploy trip-logger '{.spec.template.spec.initContainers[
|
||||
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'"""),
|
||||
&& j -n trip-tracker deploy trip-logger "$c.command[*]}" | 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}')" ] \
|
||||
@ -670,42 +666,33 @@ k -n trip-tracker rollout status deploy trip-logger
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 77 ----
|
||||
# ------------------------------------------------------------------ 410 ----
|
||||
task(
|
||||
id=77, domain="NET", title="Audit the CNI, then lock a namespace down",
|
||||
id=410, 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?
|
||||
`{WS}/q410/cni.txt`.
|
||||
2. Write `yes` or `no` to `{WS}/q410/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.
|
||||
Note which CNI is installed here before answering step 2 — several popular CNIs accept
|
||||
NetworkPolicy objects happily and never enforce them.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns zero-trust
|
||||
rm -rf "$WS/q77"; mkdir -p "$WS/q77"
|
||||
rm -rf "$WS/q410"; mkdir -p "$WS/q410"
|
||||
$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"""),
|
||||
grep -qi "$want" "$WS/q410/cni.txt" 2>/dev/null"""),
|
||||
C("enforced.txt correctly says this CNI does not enforce NetworkPolicy", 2, r"""
|
||||
grep -qix 'no' "$WS/q410/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)" '{}' \
|
||||
@ -714,11 +701,8 @@ eq "$($n -o jsonpath='{.spec.podSelector}' 2>/dev/null)" '{}' \
|
||||
&& [ -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
|
||||
k -n kube-system get ds # kindnet -> {WS}/q410/cni.txt
|
||||
echo no > {WS}/q410/enforced.txt # kindnet has no policy engine
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
@ -727,20 +711,16 @@ 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.
|
||||
# blocks listed means "deny everything". kindnet (like flannel) has no policy engine, so the API
|
||||
# server stores these objects and nothing enforces them - a real trap in a hardening review.
|
||||
# Calico and Cilium do enforce; the exam expects you to know which is which.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 78 ----
|
||||
# ------------------------------------------------------------------ 411 ----
|
||||
task(
|
||||
id=78, domain="WORK", title="Priority for the rider support portal",
|
||||
id=411, 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`.
|
||||
@ -785,9 +765,9 @@ k -n rider-support get pod -l app=support-portal -o jsonpath='{.items[0].spec.pr
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 79 ----
|
||||
# ------------------------------------------------------------------ 412 ----
|
||||
task(
|
||||
id=79, domain="WORK", title="Surge pricing configuration",
|
||||
id=412, domain="WORK", title="Surge pricing configuration",
|
||||
task="""
|
||||
In Namespace `surge-pricing`:
|
||||
|
||||
@ -850,33 +830,33 @@ k -n surge-pricing rollout status deploy surge-engine
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 80 ----
|
||||
# ------------------------------------------------------------------ 413 ----
|
||||
task(
|
||||
id=80, domain="ARCH", title="Plan the control-plane upgrade",
|
||||
id=413, 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
|
||||
1. Write the cluster's current server version, as `kubectl` reports it, to `{WS}/q413/current.txt`.
|
||||
2. Write every node's name and kubelet version, one node per line, to `{WS}/q413/kubelets.txt`.
|
||||
3. Write to `{WS}/q413/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
|
||||
4. Write to `{WS}/q413/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" """,
|
||||
setup=r"""rm -rf "$WS/q413"; mkdir -p "$WS/q413" """,
|
||||
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"""),
|
||||
[ -n "$v" ] && grep -qF "$v" "$WS/q413/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
|
||||
f="$WS/q413/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
|
||||
f="$WS/q413/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
|
||||
@ -888,13 +868,13 @@ 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" """),
|
||||
grep -Eq 'kubeadm[[:space:]]+upgrade[[:space:]]+node' "$WS/q413/worker.txt" 2>/dev/null \
|
||||
&& ! grep -q 'upgrade apply' "$WS/q413/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'
|
||||
k version -o json | python3 -c 'import sys,json;print(json.load(sys.stdin)["serverVersion"]["gitVersion"])' > {WS}/q413/current.txt
|
||||
k get nodes -o custom-columns=NAME:.metadata.name,KUBELET:.status.nodeInfo.kubeletVersion --no-headers > {WS}/q413/kubelets.txt
|
||||
cat > {WS}/q413/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
|
||||
@ -904,7 +884,7 @@ systemctl daemon-reload
|
||||
systemctl restart kubelet
|
||||
kubectl uncordon <control-plane>
|
||||
EOF
|
||||
echo 'kubeadm upgrade node' > {WS}/q80/worker.txt
|
||||
echo 'kubeadm upgrade node' > {WS}/q413/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
|
||||
@ -913,9 +893,9 @@ echo 'kubeadm upgrade node' > {WS}/q80/worker.txt
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 81 ----
|
||||
# ------------------------------------------------------------------ 414 ----
|
||||
task(
|
||||
id=81, domain="TSHOOT", title="Fix the fare calculation pods",
|
||||
id=414, 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.
|
||||
@ -1002,9 +982,9 @@ k -n fare-engine rollout status deploy fare-calculator
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 82 ----
|
||||
# ------------------------------------------------------------------ 415 ----
|
||||
task(
|
||||
id=82, domain="WORK", title="Autoscale the route planner on memory",
|
||||
id=415, domain="WORK", title="Autoscale the route planner on memory",
|
||||
task="""
|
||||
Create HorizontalPodAutoscaler `route-planner-hpa` in Namespace `route-planner` for Deployment
|
||||
`route-planner`:
|
||||
@ -1074,9 +1054,9 @@ k -n route-planner get hpa route-planner-hpa
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 83 ----
|
||||
# ------------------------------------------------------------------ 416 ----
|
||||
task(
|
||||
id=83, domain="NET", title="NetworkPolicy for the safety monitor",
|
||||
id=416, 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`:
|
||||
@ -1086,8 +1066,7 @@ In Namespace `safety-monitor`, create NetworkPolicy `safety-api-policy` for the
|
||||
- **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.)
|
||||
(kindnet does not enforce policies; you are graded on the object.)
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns safety-monitor
|
||||
@ -1147,9 +1126,9 @@ EOF
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 84 ----
|
||||
# ------------------------------------------------------------------ 417 ----
|
||||
task(
|
||||
id=84, domain="ARCH", title="Enforce TLS 1.3 on the booking portal",
|
||||
id=417, 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.
|
||||
@ -1157,13 +1136,13 @@ configuration in ConfigMap `booking-nginx-config`. It currently accepts TLS 1.2
|
||||
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`.
|
||||
`{WS}/q417/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"
|
||||
rm -rf "$WS/q417"; mkdir -p "$WS/q417"
|
||||
$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
|
||||
@ -1216,15 +1195,10 @@ $K -n booking-tls rollout status deploy booking-nginx --timeout=120s >/dev/null
|
||||
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"""),
|
||||
j -n booking-tls deploy booking-nginx '{.spec.template.metadata.annotations}' | grep -q 'restartedAt'"""),
|
||||
C("ssl-line.txt records the directive", 2, r"""
|
||||
f="$WS/q84/ssl-line.txt"
|
||||
f="$WS/q417/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
|
||||
@ -1241,7 +1215,7 @@ 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
|
||||
echo 'ssl_protocols TLSv1.3;' > {WS}/q417/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
|
||||
@ -1254,20 +1228,20 @@ echo 'ssl_protocols TLSv1.3;' > {WS}/q84/ssl-line.txt
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 85 ----
|
||||
# ------------------------------------------------------------------ 418 ----
|
||||
task(
|
||||
id=85, domain="TSHOOT", title="Investigate vehicle tracker health",
|
||||
id=418, 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`.
|
||||
`{WS}/q418/most-restarts.txt`.
|
||||
2. Save the last 50 log lines from the `app=vehicle-tracker` pods to `{WS}/q418/logs.txt`.
|
||||
3. Save the Warning events from `vehicle-track`, sorted by time, to `{WS}/q418/warnings.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns vehicle-track
|
||||
rm -rf "$WS/q85"; mkdir -p "$WS/q85"
|
||||
rm -rf "$WS/q418"; mkdir -p "$WS/q418"
|
||||
$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
|
||||
@ -1287,19 +1261,19 @@ 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
|
||||
f="$WS/q418/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"""),
|
||||
grep -q 'tracker starting\|gps fix lost' "$WS/q418/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
|
||||
f="$WS/q418/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
|
||||
k get pods -A --sort-by='.status.containerStatuses[0].restartCount' > {WS}/q418/most-restarts.txt
|
||||
k -n vehicle-track logs -l app=vehicle-tracker --tail=50 > {WS}/q418/logs.txt
|
||||
k -n vehicle-track get events --field-selector type=Warning --sort-by='.lastTimestamp' > {WS}/q418/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.
|
||||
|
||||
@ -1,988 +0,0 @@
|
||||
"""Simulator Set D - 17 tasks, fixed set, killer.sh-style.
|
||||
|
||||
Original scenarios, not copies of any commercial simulator. They exercise the techniques the
|
||||
killer.sh CKA environment is known for that the rest of the bank did not cover: saying how every
|
||||
control-plane piece is run, cluster facts to an exact file, readiness gated on a Service,
|
||||
`--sort-by` scripts, event forensics around kube-proxy with crictl, API-resource and RBAC census,
|
||||
a kubelet whose systemd unit points at the wrong binary, Secrets mounted read-only and renamed into
|
||||
env vars, a static Pod on the control plane behind a Service, kubelet client/serving certificate
|
||||
forensics, control-plane-only scheduling, stopping the scheduler and scheduling by hand, Helm
|
||||
release management (including a release stuck in pending-install), StatefulSet storage, the
|
||||
namespaceSelector-AND-podSelector NetworkPolicy trap (enforced here - kindnet enforces), a
|
||||
dnsPolicy that silently breaks name resolution, and a claim Pending on an access mode.
|
||||
|
||||
Every value a grader compares against is derived from the live cluster, never hardcoded, and every
|
||||
task was seeded, solved and graded on an isolated kind cluster before it went in.
|
||||
|
||||
Run with: ./exam sim d
|
||||
"""
|
||||
from tracks.cka.common import C, NS
|
||||
|
||||
Q = []
|
||||
CTX = "Use context: `kubectl config use-context %{ctx}`\n\n"
|
||||
|
||||
|
||||
def task(**kw):
|
||||
kw.setdefault("difficulty", "hard")
|
||||
kw.setdefault("params", lambda r: {"ctx": "k8s-c1-H"})
|
||||
kw["task"] = CTX + kw["task"].lstrip("\n")
|
||||
kw["set"] = "d"
|
||||
Q.append(kw)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 86 ----
|
||||
task(
|
||||
id=86, domain="ARCH", title="How the control plane is run",
|
||||
task="""
|
||||
The operations handbook has to say how each part of this cluster's control plane is run. Write
|
||||
`{WS}/q86/controlplane.txt` with exactly these six lines, each value one of `process`,
|
||||
`static-pod`, `pod`, `deployment` or `daemonset`:
|
||||
|
||||
```
|
||||
kubelet: <value>
|
||||
kube-apiserver: <value>
|
||||
kube-scheduler: <value>
|
||||
kube-controller-manager: <value>
|
||||
etcd: <value>
|
||||
dns: <value> <name of that object>
|
||||
```
|
||||
|
||||
`dns` is whatever serves cluster DNS, for example `dns: daemonset some-name`.
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q86"; mkdir -p "$WS/q86" """,
|
||||
checks=[
|
||||
C("kubelet is a process", 1, r"""
|
||||
f="$WS/q86/controlplane.txt"; [ -s "$f" ] || exit 1
|
||||
want=process; [ "$(nx "$CP" 'systemctl is-active kubelet' </dev/null)" = active ] || want=unknown
|
||||
eq "$(sed -n 's/^ *kubelet *: *//p' "$f" | head -1 | tr -d '[:space:]' | tr A-Z a-z)" "$want" """),
|
||||
C("apiserver, scheduler, controller-manager and etcd are static pods", 3, r"""
|
||||
f="$WS/q86/controlplane.txt"
|
||||
# a kubeadm control plane: the manifests live in the kubelet's staticPodPath on the node
|
||||
nx "$CP" 'ls /etc/kubernetes/manifests/kube-apiserver.yaml' </dev/null >/dev/null 2>&1 || exit 1
|
||||
for c in kube-apiserver kube-scheduler kube-controller-manager etcd; do
|
||||
eq "$(sed -n "s/^ *$c *: *//p" "$f" | head -1 | tr -d '[:space:]' | tr A-Z a-z)" static-pod || exit 1
|
||||
done"""),
|
||||
C("dns names the right kind of object and its name", 2, r"""
|
||||
f="$WS/q86/controlplane.txt"
|
||||
sel=$(j -n kube-system svc kube-dns '{.spec.selector.k8s-app}')
|
||||
own=$(j -n kube-system pod -l k8s-app=$sel '{.items[0].metadata.ownerReferences[0].kind}')
|
||||
name=$(j -n kube-system pod -l k8s-app=$sel '{.items[0].metadata.ownerReferences[0].name}')
|
||||
if [ "$own" = ReplicaSet ]; then want="deployment $(j -n kube-system rs "$name" '{.metadata.ownerReferences[0].name}')"
|
||||
else want="$(echo "$own" | tr A-Z a-z) $name"; fi
|
||||
eq "$(sed -n 's/^ *dns *: *//p' "$f" | head -1 | tr -s ' ' | tr A-Z a-z | sed 's/ *$//')" "$want" """),
|
||||
],
|
||||
solution="""
|
||||
{SHELL_CP}
|
||||
systemctl status kubelet | head -3 # a systemd service: a plain process on the node
|
||||
ls /etc/kubernetes/manifests # etcd, kube-apiserver, kube-controller-manager, kube-scheduler
|
||||
exit
|
||||
k -n kube-system get pod -o wide # static pods carry the node name as a suffix...
|
||||
k -n kube-system get pod kube-apiserver-{CP} -o jsonpath='{.metadata.ownerReferences[0].kind}{"\\n"}' # ...and are owned by the Node
|
||||
k -n kube-system get deploy,ds # coredns is a Deployment (kube-proxy, kindnet are DaemonSets)
|
||||
cat > {WS}/q86/controlplane.txt <<'EOF'
|
||||
kubelet: process
|
||||
kube-apiserver: static-pod
|
||||
kube-scheduler: static-pod
|
||||
kube-controller-manager: static-pod
|
||||
etcd: static-pod
|
||||
dns: deployment coredns
|
||||
EOF
|
||||
# The Service is called kube-dns for historical reasons; what actually answers is the coredns
|
||||
# Deployment. Follow the chain svc selector -> pod -> ownerReferences when unsure.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 87 ----
|
||||
task(
|
||||
id=87, domain="TSHOOT", title="Cluster facts to an exact file",
|
||||
task="""
|
||||
Write `{WS}/q87/cluster-info.txt` — answers only, exactly this format:
|
||||
|
||||
```
|
||||
1: <number of control-plane nodes>
|
||||
2: <number of worker nodes>
|
||||
3: <the Service CIDR>
|
||||
4: <name of the CNI plugin>, <full path of its configuration file on the nodes>
|
||||
5: <the suffix the names of static Pods running on node {W3} end with>
|
||||
```
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q87"; mkdir -p "$WS/q87" """,
|
||||
checks=[
|
||||
C("node counts (lines 1 and 2)", 2, r"""
|
||||
f="$WS/q87/cluster-info.txt"; [ -s "$f" ] || exit 1
|
||||
cp=$($K get nodes -l node-role.kubernetes.io/control-plane --no-headers 2>/dev/null | wc -l); all=$($K get nodes --no-headers | wc -l)
|
||||
eq "$(sed -n 's/^ *1 *: *//p' "$f" | tr -d '[:space:]')" "$cp" && eq "$(sed -n 's/^ *2 *: *//p' "$f" | tr -d '[:space:]')" "$((all - cp))" """),
|
||||
C("Service CIDR (line 3)", 2, r"""
|
||||
sc=$(j -n kube-system pod -l component=kube-apiserver '{.items[0].spec.containers[0].command}' | tr ',' '\n' | grep -o 'service-cluster-ip-range=[^"]*' | cut -d= -f2)
|
||||
[ -n "$sc" ] && eq "$(sed -n 's/^ *3 *: *//p' "$WS/q87/cluster-info.txt" | tr -d '[:space:]')" "$sc" """),
|
||||
C("CNI plugin and its config file (line 4)", 2, r"""
|
||||
l=$(sed -n 's/^ *4 *: *//p' "$WS/q87/cluster-info.txt" | tr -d '[:space:]')
|
||||
cni=$($K -n kube-system get ds -o name | sed 's|.*/||' | grep -Ei 'kindnet|calico|cilium|flannel|weave' | head -1)
|
||||
cfg=$(nx "$W1" 'ls /etc/cni/net.d/*.conflist /etc/cni/net.d/*.conf 2>/dev/null | head -1' </dev/null)
|
||||
[ -n "$cni" ] && [ -n "$cfg" ] && echo "$l" | grep -qi "$cni" && echo "$l" | grep -qF "$cfg" """),
|
||||
C("static Pod name suffix on {W3} (line 5)", 1, r"""
|
||||
v=$(sed -n 's/^ *5 *: *//p' "$WS/q87/cluster-info.txt" | tr -d '[:space:]'); [ "$v" = "-$W3" ] || [ "$v" = "$W3" ]"""),
|
||||
],
|
||||
solution="""
|
||||
k get nodes # ROLES shows control-plane; count the rest
|
||||
k -n kube-system get pod -l component=kube-apiserver -o yaml | grep service-cluster-ip-range
|
||||
k -n kube-system get ds # kindnet
|
||||
{SHELL_W1}
|
||||
ls /etc/cni/net.d/ # 10-kindnet.conflist
|
||||
exit
|
||||
cat > {WS}/q87/cluster-info.txt <<'EOF'
|
||||
1: 1
|
||||
2: 3
|
||||
3: 10.96.0.0/16
|
||||
4: kindnet, /etc/cni/net.d/10-kindnet.conflist
|
||||
5: -{W3}
|
||||
EOF
|
||||
# A static Pod's mirror is named <pod>-<node name>, e.g. kube-apiserver-{CP}. Values come from THIS
|
||||
# cluster - check each one instead of trusting the ones shown here.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 88 ----
|
||||
task(
|
||||
id=88, domain="NET", title="Ready only when its dependency answers",
|
||||
task="""
|
||||
In Namespace `oslo-web`:
|
||||
|
||||
1. Create Pod `gatekeeper` (image `busybox:1.36`, command `sleep 86400`) with an **exec readiness
|
||||
probe** that runs `wget -T2 -qO- http://ledger-api:8080`, initial delay 5 seconds, period 5
|
||||
seconds. For now it must stay **not ready** — confirm that it does.
|
||||
2. Deployment `ledger` serves HTTP on port 80, but nothing exposes it. Create the Service
|
||||
`ledger-api` (ClusterIP, port **8080**) for it, so that `gatekeeper` turns Ready without you
|
||||
touching the Pod again.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns oslo-web
|
||||
$K -n oslo-web delete pod gatekeeper --ignore-not-found --force --grace-period=0 >/dev/null 2>&1
|
||||
$K -n oslo-web delete svc ledger-api --ignore-not-found >/dev/null
|
||||
$K -n oslo-web create deploy ledger --image=nginx:1.27 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n oslo-web rollout status deploy ledger --timeout=120s >/dev/null 2>&1
|
||||
""",
|
||||
checks=[
|
||||
C("gatekeeper has the exec readiness probe (delay 5, period 5)", 2, r"""
|
||||
p='{.spec.containers[0].readinessProbe'
|
||||
j -n oslo-web pod gatekeeper "$p.exec.command}" | grep -q 'ledger-api:8080' \
|
||||
&& eq "$(j -n oslo-web pod gatekeeper "$p.initialDelaySeconds}")" 5 && eq "$(j -n oslo-web pod gatekeeper "$p.periodSeconds}")" 5"""),
|
||||
C("Service ledger-api: 8080 -> 80 with ready endpoints", 2, r"""
|
||||
eq "$(j -n oslo-web svc ledger-api '{.spec.ports[0].port}')" 8080 && eq "$(j -n oslo-web svc ledger-api '{.spec.ports[0].targetPort}')" 80 \
|
||||
&& [ -n "$(j -n oslo-web endpointslices -l kubernetes.io/service-name=ledger-api '{.items[*].endpoints[?(@.conditions.ready==true)].addresses[*]}')" ]"""),
|
||||
C("gatekeeper is Ready", 3, r"""
|
||||
for i in $(seq 1 20); do [ "$(j -n oslo-web pod gatekeeper '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && exit 0; sleep 3; done; exit 1"""),
|
||||
],
|
||||
solution="""
|
||||
k -n oslo-web run gatekeeper --image=busybox:1.36 $do -- sleep 86400 > gk.yaml
|
||||
# add to the container:
|
||||
# readinessProbe:
|
||||
# exec:
|
||||
# command: ["sh", "-c", "wget -T2 -qO- http://ledger-api:8080"]
|
||||
# initialDelaySeconds: 5
|
||||
# periodSeconds: 5
|
||||
k apply -f gk.yaml
|
||||
k -n oslo-web get pod gatekeeper # 0/1 Running - the probe cannot resolve ledger-api
|
||||
k -n oslo-web describe pod gatekeeper | tail -3 # Readiness probe failed: wget: bad address 'ledger-api:8080'
|
||||
k -n oslo-web expose deploy ledger --name=ledger-api --port=8080 --target-port=80
|
||||
k -n oslo-web get pod gatekeeper -w # 1/1 after the next probe
|
||||
# Readiness removes a Pod from Service endpoints and blocks rollouts; liveness restarts it.
|
||||
# A dependency check belongs in readiness - failing liveness here would just restart-loop the Pod.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 89 ----
|
||||
task(
|
||||
id=89, domain="WORK", title="Sorting done by kubectl",
|
||||
task="""
|
||||
Write two executable scripts that call `kubectl` (spelled out — aliases do not exist in a fresh
|
||||
shell) and let **kubectl itself** do the sorting with `--sort-by`:
|
||||
|
||||
1. `{WS}/q89/pods-by-age.sh` — every Pod in every Namespace, oldest first (by creation time).
|
||||
2. `{WS}/q89/pods-by-uid.sh` — the Pods in Namespace `kube-system`, sorted by `metadata.uid`.
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q89"; mkdir -p "$WS/q89" """,
|
||||
checks=[
|
||||
C("pods-by-age.sh: all namespaces, --sort-by creationTimestamp, runs", 2, r"""
|
||||
f="$WS/q89/pods-by-age.sh"; [ -x "$f" ] || exit 1
|
||||
grep -Eq 'kubectl\b.*\bget\b.*\b(po|pods?)\b' "$f" && grep -Eq -- '(-A|--all-namespaces)' "$f" \
|
||||
&& grep -Eq -- "--sort-by[= ]*['\"]?\{?\.?metadata\.creationTimestamp" "$f" || exit 1
|
||||
out=$(timeout 60 bash "$f" 2>/dev/null); [ "$(echo "$out" | wc -l)" -ge 5 ] && echo "$out" | grep -q kube-system"""),
|
||||
C("pods-by-uid.sh: kube-system, --sort-by metadata.uid, runs", 2, r"""
|
||||
f="$WS/q89/pods-by-uid.sh"; [ -x "$f" ] || exit 1
|
||||
grep -Eq 'kubectl\b.*\bget\b.*\b(po|pods?)\b' "$f" && grep -Eq -- '(-n|--namespace)[= ]*kube-system' "$f" \
|
||||
&& grep -Eq -- "--sort-by[= ]*['\"]?\{?\.?metadata\.uid" "$f" || exit 1
|
||||
timeout 60 bash "$f" 2>/dev/null | grep -q etcd"""),
|
||||
C("no aliases in either script", 1, r"""[ -s "$WS/q89/pods-by-age.sh" ] && [ -s "$WS/q89/pods-by-uid.sh" ] && ! grep -Eq '(^|[;|&(] *| )k +' "$WS/q89/pods-by-age.sh" "$WS/q89/pods-by-uid.sh" """),
|
||||
],
|
||||
solution="""
|
||||
cat > {WS}/q89/pods-by-age.sh <<'EOF'
|
||||
kubectl get pods -A --sort-by=.metadata.creationTimestamp
|
||||
EOF
|
||||
cat > {WS}/q89/pods-by-uid.sh <<'EOF'
|
||||
kubectl -n kube-system get pods --sort-by=.metadata.uid
|
||||
EOF
|
||||
chmod +x {WS}/q89/*.sh
|
||||
bash {WS}/q89/pods-by-age.sh | head -3 # the control-plane pods come first - they are oldest
|
||||
# --sort-by takes a JSONPath into each item; ascending only, so "newest first" is `| tac` or
|
||||
# sorting by a field that grows the other way.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 90 ----
|
||||
task(
|
||||
id=90, domain="TSHOOT", title="Event forensics around kube-proxy", breaks="w2-kube-proxy",
|
||||
task="""
|
||||
1. Write `{WS}/q90/cluster-events.sh`: a script that prints the latest events of the **whole**
|
||||
cluster (all Namespaces) sorted by creation time, using `kubectl`. Make it executable.
|
||||
2. Delete the kube-proxy Pod that runs on node `{W2}`. Write the events this caused to
|
||||
`{WS}/q90/pod-deleted.log`.
|
||||
3. On node `{W2}`, stop **and remove** the kube-proxy **container** with `crictl` — leave the Pod
|
||||
object alone. Write the events this caused to `{WS}/q90/container-killed.log`.
|
||||
4. Which of the two actions made the Pod's `RESTARTS` counter go up? Write `pod` or `container` to
|
||||
`{WS}/q90/restarts.txt`.
|
||||
|
||||
Do step 2 before step 3.
|
||||
""",
|
||||
setup=r"""
|
||||
rm -rf "$WS/q90"; mkdir -p "$WS/q90"
|
||||
$K -n kube-system get pod -l k8s-app=kube-proxy --field-selector spec.nodeName=$W2 -o jsonpath='{.items[0].metadata.uid}' > "$WS/.q90-uid"
|
||||
""",
|
||||
checks=[
|
||||
C("cluster-events.sh: all namespaces, sorted by time, runs", 2, r"""
|
||||
f="$WS/q90/cluster-events.sh"; [ -x "$f" ] || exit 1
|
||||
grep -Eq 'kubectl\b.*\bget\b.*\b(ev|events?)\b' "$f" && grep -Eq -- '(-A|--all-namespaces)' "$f" \
|
||||
&& grep -Eq -- "--sort-by[= ]*['\"]?\{?\.?(metadata\.creationTimestamp|lastTimestamp|eventTime|firstTimestamp)" "$f" || exit 1
|
||||
timeout 60 bash "$f" >/dev/null 2>&1"""),
|
||||
C("the kube-proxy Pod on {W2} was deleted and replaced", 2, r"""
|
||||
old=$(cat "$WS/.q90-uid" 2>/dev/null); [ -n "$old" ] || exit 1
|
||||
now=$(j -n kube-system pod -l k8s-app=kube-proxy --field-selector spec.nodeName=$W2 '{.items[0].metadata.uid}')
|
||||
[ -n "$now" ] && [ "$now" != "$old" ] && eq "$(j -n kube-system pod -l k8s-app=kube-proxy --field-selector spec.nodeName=$W2 '{.items[0].status.phase}')" Running"""),
|
||||
# counted on the REPLACEMENT pod, which starts at 0 - after a node reboot the original pod
|
||||
# often already shows a restart, which would otherwise hand these points out for free
|
||||
C("then its container was killed (restart counter >= 1)", 2, r"""
|
||||
old=$(cat "$WS/.q90-uid" 2>/dev/null); now=$(j -n kube-system pod -l k8s-app=kube-proxy --field-selector spec.nodeName=$W2 '{.items[0].metadata.uid}')
|
||||
[ -n "$old" ] && [ -n "$now" ] && [ "$now" != "$old" ] || exit 1
|
||||
[ "$(j -n kube-system pod -l k8s-app=kube-proxy --field-selector spec.nodeName=$W2 '{.items[0].status.containerStatuses[0].restartCount}')" -ge 1 ] 2>/dev/null"""),
|
||||
C("pod-deleted.log and container-killed.log hold the events", 2, r"""
|
||||
for f in pod-deleted.log container-killed.log; do
|
||||
grep -q 'kube-proxy' "$WS/q90/$f" 2>/dev/null && grep -Eq 'Killing|SuccessfulCreate|SuccessfulDelete|Scheduled|Pulled|Created|Started|BackOff' "$WS/q90/$f" || exit 1
|
||||
done"""),
|
||||
C("restarts.txt says container", 2, r"""eq "$(tr -d '[:space:]' < "$WS/q90/restarts.txt" 2>/dev/null | tr A-Z a-z)" container"""),
|
||||
],
|
||||
solution="""
|
||||
cat > {WS}/q90/cluster-events.sh <<'EOF'
|
||||
kubectl get events -A --sort-by=.metadata.creationTimestamp
|
||||
EOF
|
||||
chmod +x {WS}/q90/cluster-events.sh
|
||||
|
||||
k -n kube-system get pod -l k8s-app=kube-proxy -o wide # the one on {W2}
|
||||
k -n kube-system delete pod <kube-proxy-on-{W2}>
|
||||
k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-proxy | tail -8 > {WS}/q90/pod-deleted.log
|
||||
# Killing (old pod), SuccessfulCreate (DaemonSet), Scheduled, Pulled, Created, Started (new pod)
|
||||
|
||||
{SHELL_W2}
|
||||
crictl ps --name kube-proxy # container id
|
||||
crictl stop <id> && crictl rm <id>
|
||||
exit
|
||||
k -n kube-system get events --sort-by=.metadata.creationTimestamp | grep kube-proxy | tail -4 > {WS}/q90/container-killed.log
|
||||
# Pulled, Created, Started - same Pod, new container
|
||||
k -n kube-system get pod -l k8s-app=kube-proxy -o wide # RESTARTS 1 on {W2}
|
||||
echo container > {WS}/q90/restarts.txt
|
||||
# Deleting the Pod makes the DaemonSet create a brand-new Pod (RESTARTS 0). Killing only the
|
||||
# container makes the kubelet start a new container inside the SAME Pod - that is a restart.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 91 ----
|
||||
def _census(r):
|
||||
n = r.sample(range(2, 10), 4)
|
||||
return {"ctx": "k8s-c1-H", "north": n[0], "south": n[1], "east": n[2], "west": n[3]}
|
||||
|
||||
|
||||
task(
|
||||
id=91, domain="ARCH", title="API resources and a Role census", params=_census,
|
||||
task="""
|
||||
1. Write the names of every **namespaced** resource type this API server offers to
|
||||
`{WS}/q91/namespaced.txt`, one per line — plural names, as `kubectl api-resources` prints them.
|
||||
2. Of the Namespaces `census-north`, `census-south`, `census-east` and `census-west`, which one holds
|
||||
the most Roles? Write `<namespace> <number of Roles>` to `{WS}/q91/most-roles.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
rm -rf "$WS/q91"; mkdir -p "$WS/q91"
|
||||
for pair in north:%{north} south:%{south} east:%{east} west:%{west}; do
|
||||
ns=census-${pair%%:*}; n=${pair##*:}
|
||||
mkns $ns
|
||||
$K -n $ns delete role --all >/dev/null 2>&1
|
||||
for i in $(seq 1 $n); do $K -n $ns create role reader-$i --verb=get,list --resource=configmaps >/dev/null; done
|
||||
done
|
||||
""",
|
||||
checks=[
|
||||
C("namespaced.txt lists exactly the namespaced resource types", 3, r"""
|
||||
f="$WS/q91/namespaced.txt"; [ -s "$f" ] || exit 1
|
||||
want=$($K api-resources --namespaced=true -o name 2>/dev/null | sed 's/\..*//' | sort -u)
|
||||
got=$(awk '{print $1}' "$f" | sed 's/\..*//' | grep . | sort -u)
|
||||
[ -n "$want" ] && [ "$got" = "$want" ]"""),
|
||||
C("most-roles.txt names the namespace and its Role count", 3, r"""
|
||||
best=$(for ns in census-north census-south census-east census-west; do echo "$ns $($K -n $ns get role --no-headers 2>/dev/null | wc -l)"; done | sort -k2 -n | tail -1)
|
||||
eq "$(tr -s ' ' < "$WS/q91/most-roles.txt" 2>/dev/null | sed 's/^ *//; s/ *$//')" "$best" """),
|
||||
],
|
||||
solution="""
|
||||
k api-resources --namespaced=true -o name > /tmp/r.txt # deployments.apps, ... (group suffix)
|
||||
k api-resources --namespaced=true --no-headers | awk '{print $1}' > {WS}/q91/namespaced.txt
|
||||
for ns in census-north census-south census-east census-west; do echo "$ns $(k -n $ns get role --no-headers | wc -l)"; done
|
||||
# pick the largest, e.g. echo "census-east 8" > {WS}/q91/most-roles.txt
|
||||
# --namespaced=false gives the cluster-scoped half (nodes, pv, storageclasses, clusterroles...).
|
||||
# `k get role -A --no-headers | awk '{print $1}' | sort | uniq -c | sort -n` counts every
|
||||
# namespace at once.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 92 ----
|
||||
task(
|
||||
id=92, domain="TSHOOT", title="Kubelet unit points at nothing", breaks="w1-kubelet", disruptive=True,
|
||||
task="""
|
||||
Node `{W1}` went `NotReady` after someone "tidied up" its kubelet installation. Bring it back to
|
||||
`Ready`; the kubelet has to run as the systemd service it always was. Then write the full path of the
|
||||
file you had to fix to `{WS}/q92/fixed-file.txt`.
|
||||
|
||||
Reach the node with `{SHELL_W1}`. Pods on a node that stays NotReady are evicted after five minutes.
|
||||
""",
|
||||
setup=r"""
|
||||
rm -rf "$WS/q92"; mkdir -p "$WS/q92"
|
||||
nx "$W1" '
|
||||
d=/etc/systemd/system/kubelet.service.d/10-kubeadm.conf
|
||||
grep -q "^ExecStart=/usr/bin/kubelet " $d && sed -i "s|^ExecStart=/usr/bin/kubelet |ExecStart=/usr/local/bin/kubelet |" $d
|
||||
systemctl daemon-reload; systemctl restart kubelet' </dev/null >/dev/null 2>&1
|
||||
exit 0
|
||||
""",
|
||||
checks=[
|
||||
C("{W1} is Ready", 4, r"""for i in $(seq 1 15); do [ "$(j node "$W1" '{.status.conditions[?(@.type=="Ready")].status}')" = True ] && exit 0; sleep 4; done; exit 1"""),
|
||||
C("kubelet active, and its unit runs a kubelet binary that exists", 2, r"""
|
||||
nx "$W1" 'systemctl is-active --quiet kubelet && b=$(sed -n "s|^ExecStart=\([^ ]*kubelet\) .*|\1|p" /etc/systemd/system/kubelet.service.d/10-kubeadm.conf | tail -1) && [ -x "$b" ]' </dev/null"""),
|
||||
C("fixed-file.txt names the drop-in", 2, r"""grep -q '/etc/systemd/system/kubelet.service.d/10-kubeadm.conf' "$WS/q92/fixed-file.txt" 2>/dev/null"""),
|
||||
],
|
||||
solution="""
|
||||
k get nodes # {W1} NotReady
|
||||
{SHELL_W1}
|
||||
systemctl status kubelet
|
||||
# Process: ... ExecStart=/usr/local/bin/kubelet ... (code=exited, status=203/EXEC)
|
||||
journalctl -u kubelet -n 20 --no-pager # Unable to locate executable '/usr/local/bin/kubelet'
|
||||
which kubelet # /usr/bin/kubelet - the binary was never moved
|
||||
systemctl cat kubelet # shows the unit AND every drop-in, with file names
|
||||
vi /etc/systemd/system/kubelet.service.d/10-kubeadm.conf # ExecStart=/usr/bin/kubelet ...
|
||||
systemctl daemon-reload # systemd caches unit files - without this nothing changes
|
||||
systemctl restart kubelet
|
||||
systemctl status kubelet # active (running)
|
||||
exit
|
||||
echo /etc/systemd/system/kubelet.service.d/10-kubeadm.conf > {WS}/q92/fixed-file.txt
|
||||
k get nodes -w
|
||||
# status=203/EXEC means systemd could not exec the binary at all - a path problem, not a kubelet
|
||||
# configuration problem. `ExecStart=` (empty) in a drop-in clears the unit's own ExecStart first.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 93 ----
|
||||
task(
|
||||
id=93, domain="WORK", title="Secrets as a read-only volume and renamed env vars",
|
||||
params=lambda r: {"ctx": "k8s-c1-H", "pw": "ledger-" + "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(10))},
|
||||
task="""
|
||||
In Namespace `oslo-secure`:
|
||||
|
||||
1. Create Secret `bundle` from the file `{WS}/q93/tls-bundle.pem`; the key must be the file's name.
|
||||
2. Create Secret `db-login` with `user=svc-ledger` and `pass=%{pw}`.
|
||||
3. Create Pod `vault-reader` (image `busybox:1.36`, command `sleep 86400`) that
|
||||
- mounts Secret `bundle` **read-only** at `/etc/bundle`,
|
||||
- receives `db-login`'s keys as environment variables named **`APP_USER`** and **`APP_PASS`**.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns oslo-secure
|
||||
rm -rf "$WS/q93"; mkdir -p "$WS/q93"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -subj "/CN=ledger.oslo.internal" -keyout /dev/null -out "$WS/q93/tls-bundle.pem" >/dev/null 2>&1
|
||||
$K -n oslo-secure delete pod vault-reader --ignore-not-found --force --grace-period=0 >/dev/null 2>&1
|
||||
$K -n oslo-secure delete secret bundle db-login --ignore-not-found >/dev/null
|
||||
""",
|
||||
checks=[
|
||||
C("Secrets bundle (key tls-bundle.pem, the file's content) and db-login", 2, r"""
|
||||
[ "$(j -n oslo-secure secret bundle '{.data.tls-bundle\.pem}' | base64 -d 2>/dev/null)" = "$(cat "$WS/q93/tls-bundle.pem")" ] \
|
||||
&& eq "$(j -n oslo-secure secret db-login '{.data.user}' | base64 -d)" svc-ledger && eq "$(j -n oslo-secure secret db-login '{.data.pass}' | base64 -d)" '%{pw}'"""),
|
||||
C("vault-reader Running with bundle mounted read-only at /etc/bundle", 3, r"""
|
||||
p="$K -n oslo-secure get pod vault-reader"
|
||||
eq "$($p -o jsonpath='{.status.phase}' 2>/dev/null)" Running || exit 1
|
||||
v=$($p -o jsonpath='{.spec.volumes[?(@.secret.secretName=="bundle")].name}'); [ -n "$v" ] || exit 1
|
||||
eq "$($p -o jsonpath="{.spec.containers[0].volumeMounts[?(@.name==\"$v\")].mountPath}")" /etc/bundle \
|
||||
&& eq "$($p -o jsonpath="{.spec.containers[0].volumeMounts[?(@.name==\"$v\")].readOnly}")" true \
|
||||
&& $K -n oslo-secure exec vault-reader -- cat /etc/bundle/tls-bundle.pem 2>/dev/null | grep -q 'BEGIN CERTIFICATE'"""),
|
||||
C("APP_USER / APP_PASS come from db-login", 3, r"""
|
||||
e=$(j -n oslo-secure pod vault-reader '{.spec.containers[0].env}')
|
||||
echo "$e" | grep -q '"name":"APP_USER"' && echo "$e" | grep -q '"name":"APP_PASS"' && echo "$e" | grep -q 'db-login' || exit 1
|
||||
env=$($K -n oslo-secure exec vault-reader -- env 2>/dev/null)
|
||||
echo "$env" | grep -qx 'APP_USER=svc-ledger' && echo "$env" | grep -qx 'APP_PASS=%{pw}'"""),
|
||||
],
|
||||
solution="""
|
||||
k -n oslo-secure create secret generic bundle --from-file={WS}/q93/tls-bundle.pem # key = file name
|
||||
k -n oslo-secure create secret generic db-login --from-literal=user=svc-ledger --from-literal=pass='%{pw}'
|
||||
k -n oslo-secure run vault-reader --image=busybox:1.36 $do -- sleep 86400 > vr.yaml
|
||||
# in vr.yaml, pod level:
|
||||
# volumes: [{name: bundle, secret: {secretName: bundle}}]
|
||||
# in the container:
|
||||
# volumeMounts: [{name: bundle, mountPath: /etc/bundle, readOnly: true}]
|
||||
# env:
|
||||
# - name: APP_USER
|
||||
# valueFrom: {secretKeyRef: {name: db-login, key: user}}
|
||||
# - name: APP_PASS
|
||||
# valueFrom: {secretKeyRef: {name: db-login, key: pass}}
|
||||
k apply -f vr.yaml
|
||||
k -n oslo-secure exec vault-reader -- env | grep APP_
|
||||
k -n oslo-secure exec vault-reader -- ls /etc/bundle
|
||||
# envFrom would inject the keys under their own names (user, pass); renaming needs env + secretKeyRef.
|
||||
# A Secret volume updates in place when the Secret changes; env vars never do.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 94 ----
|
||||
task(
|
||||
id=94, domain="ARCH", title="Static Pod on the control plane behind a Service",
|
||||
task="""
|
||||
1. On the control-plane node (`{SHELL_CP}`) create a **static Pod** `ops-beacon` in Namespace
|
||||
`default`: image `nginx:1.27`, requests `cpu: 10m` and `memory: 20Mi`, label `app=ops-beacon`.
|
||||
2. Expose it with a Service `ops-beacon-svc` of type **NodePort** (port 80 → 80) whose endpoints
|
||||
contain that static Pod.
|
||||
3. Write the node port the Service was given to `{WS}/q94/nodeport.txt`.
|
||||
""",
|
||||
setup=r"""
|
||||
rm -rf "$WS/q94"; mkdir -p "$WS/q94"
|
||||
nx "$CP" 'rm -f /etc/kubernetes/manifests/ops-beacon*.yaml' </dev/null
|
||||
$K -n default delete svc ops-beacon-svc --ignore-not-found >/dev/null
|
||||
# the kubelet drops the mirror pod on its next sync of the manifest directory
|
||||
for i in $(seq 1 30); do $K -n default get pod "ops-beacon-$CP" >/dev/null 2>&1 || break; sleep 2; done
|
||||
""",
|
||||
checks=[
|
||||
C("manifest in the control-plane node's staticPodPath", 1, r"""
|
||||
nx "$CP" 'd=$(awk "/^staticPodPath:/{print \$2}" /var/lib/kubelet/config.yaml); grep -ls "ops-beacon" "$d"/*.yaml' </dev/null >/dev/null 2>&1"""),
|
||||
C("mirror Pod ops-beacon-{CP} Running with the requests and label", 3, r"""
|
||||
p="$K -n default get pod ops-beacon-$CP"
|
||||
eq "$($p -o jsonpath='{.status.phase}' 2>/dev/null)" Running && eq "$($p -o jsonpath='{.metadata.labels.app}')" ops-beacon \
|
||||
&& eq "$($p -o jsonpath='{.spec.containers[0].resources.requests.cpu}')" 10m && eq "$($p -o jsonpath='{.spec.containers[0].resources.requests.memory}')" 20Mi"""),
|
||||
C("NodePort Service ops-beacon-svc 80 -> 80 with the static Pod as endpoint", 3, r"""
|
||||
s="$K -n default get svc ops-beacon-svc"
|
||||
eq "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" NodePort && eq "$($s -o jsonpath='{.spec.ports[0].port}')" 80 && eq "$($s -o jsonpath='{.spec.ports[0].targetPort}')" 80 || exit 1
|
||||
ip=$(j -n default pod "ops-beacon-$CP" '{.status.podIP}'); [ -n "$ip" ] || exit 1
|
||||
j -n default endpointslices -l kubernetes.io/service-name=ops-beacon-svc '{.items[*].endpoints[*].addresses[*]}' | tr ' ' '\n' | grep -qx "$ip" """),
|
||||
C("nodeport.txt holds the Service's node port", 1, r"""np=$(j -n default svc ops-beacon-svc '{.spec.ports[0].nodePort}'); [ -n "$np" ] && eq "$(tr -d '[:space:]' < "$WS/q94/nodeport.txt" 2>/dev/null)" "$np" """),
|
||||
],
|
||||
solution="""
|
||||
k run ops-beacon -n default --image=nginx:1.27 --labels=app=ops-beacon $do > beacon.yaml
|
||||
# add to the container: resources: {requests: {cpu: 10m, memory: 20Mi}}
|
||||
{SHELL_CP}
|
||||
grep staticPodPath /var/lib/kubelet/config.yaml # /etc/kubernetes/manifests
|
||||
vi /etc/kubernetes/manifests/ops-beacon.yaml # paste beacon.yaml
|
||||
exit
|
||||
k -n default get pod ops-beacon-{CP} -o wide # mirror pod, named <pod>-<node>
|
||||
k -n default expose pod ops-beacon-{CP} --name=ops-beacon-svc --type=NodePort --port=80 --target-port=80
|
||||
k -n default get endpointslices -l kubernetes.io/service-name=ops-beacon-svc # the mirror pod's IP
|
||||
k -n default get svc ops-beacon-svc -o jsonpath='{.spec.ports[0].nodePort}' > {WS}/q94/nodeport.txt
|
||||
# The control-plane taint only keeps the SCHEDULER from placing pods there; the kubelet runs its
|
||||
# static pods regardless. `expose pod` copies the pod's labels into the selector.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 95 ----
|
||||
task(
|
||||
id=95, domain="ARCH", title="The kubelet's two certificates",
|
||||
task="""
|
||||
On node `{W2}` find the certificate the kubelet uses as a **client** (to talk to the API server)
|
||||
and the one it **serves** its own API with (port 10250). Write exactly this to
|
||||
`{WS}/q95/kubelet-certs.txt`:
|
||||
|
||||
```
|
||||
client-issuer: <issuer, as openssl prints it>
|
||||
client-eku: <the extended key usage>
|
||||
server-issuer: <issuer>
|
||||
server-eku: <the extended key usage>
|
||||
```
|
||||
|
||||
Reach the node with `{SHELL_W2}`.
|
||||
""",
|
||||
setup=r"""rm -rf "$WS/q95"; mkdir -p "$WS/q95" """,
|
||||
checks=[
|
||||
C("client certificate: issuer and extended key usage", 3, r"""
|
||||
f="$WS/q95/kubelet-certs.txt"; [ -s "$f" ] || exit 1
|
||||
iss=$(nx "$W2" 'openssl x509 -in /var/lib/kubelet/pki/kubelet-client-current.pem -noout -issuer' </dev/null | sed 's/^issuer= *//' | tr -d ' ' | tr A-Z a-z)
|
||||
[ -n "$iss" ] && sed -n 's/^ *client-issuer *: *//p' "$f" | tr -d ' ' | tr A-Z a-z | grep -qF "$iss" \
|
||||
&& sed -n 's/^ *client-eku *: *//p' "$f" | grep -qi 'client auth'"""),
|
||||
C("serving certificate: issuer and extended key usage", 3, r"""
|
||||
f="$WS/q95/kubelet-certs.txt"
|
||||
iss=$(nx "$W2" 'openssl x509 -in /var/lib/kubelet/pki/kubelet.crt -noout -issuer' </dev/null | sed 's/^issuer= *//' | tr -d ' ' | tr A-Z a-z)
|
||||
[ -n "$iss" ] && sed -n 's/^ *server-issuer *: *//p' "$f" | tr -d ' ' | tr A-Z a-z | grep -qF "$iss" \
|
||||
&& sed -n 's/^ *server-eku *: *//p' "$f" | grep -qi 'server auth'"""),
|
||||
],
|
||||
solution="""
|
||||
{SHELL_W2}
|
||||
grep -A3 users: /etc/kubernetes/kubelet.conf # client-certificate: /var/lib/kubelet/pki/kubelet-client-current.pem
|
||||
ls /var/lib/kubelet/pki/ # kubelet.crt / kubelet.key = the serving pair
|
||||
openssl x509 -in /var/lib/kubelet/pki/kubelet-client-current.pem -noout -text | grep -E 'Issuer|Extended Key Usage' -A1
|
||||
# Issuer: CN = kubernetes Extended Key Usage: TLS Web Client Authentication
|
||||
openssl x509 -in /var/lib/kubelet/pki/kubelet.crt -noout -text | grep -E 'Issuer|Extended Key Usage' -A1
|
||||
# Issuer: CN = {W2}-ca@<timestamp> Extended Key Usage: TLS Web Server Authentication
|
||||
exit
|
||||
# write the four lines with the values YOUR openssl printed:
|
||||
# client-issuer: CN=kubernetes
|
||||
# client-eku: TLS Web Client Authentication
|
||||
# server-issuer: CN={W2}-ca@<timestamp>
|
||||
# server-eku: TLS Web Server Authentication
|
||||
# The client cert is signed by the cluster CA and rotated by the kubelet through CSRs. The serving
|
||||
# cert here is self-signed by the kubelet at startup (no serverTLSBootstrap) - which is exactly why
|
||||
# metrics-server needs --kubelet-insecure-tls on this cluster.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 96 ----
|
||||
task(
|
||||
id=96, domain="WORK", title="A Pod for the control plane only",
|
||||
task="""
|
||||
Create Pod `cp-watch` in Namespace `default`: image `httpd:2-alpine`, container name `watch`.
|
||||
|
||||
It must be able to run **only** on control-plane nodes. Select them by the node label kubeadm puts
|
||||
on control-plane nodes — not by host name, and not with `nodeName` — and tolerate whatever keeps
|
||||
ordinary Pods off them. It must be Running.
|
||||
""",
|
||||
setup=r"""$K -n default delete pod cp-watch --ignore-not-found --force --grace-period=0 >/dev/null 2>&1""",
|
||||
checks=[
|
||||
C("cp-watch Running on the control-plane node, container named watch", 2, r"""
|
||||
eq "$(j -n default pod cp-watch '{.status.phase}')" Running && eq "$(j -n default pod cp-watch '{.spec.nodeName}')" "$CP" && eq "$(j -n default pod cp-watch '{.spec.containers[0].name}')" watch"""),
|
||||
C("selected by the node-role.kubernetes.io/control-plane label", 2, r"""
|
||||
s=$(j -n default pod cp-watch '{.spec.nodeSelector}{.spec.affinity.nodeAffinity}')
|
||||
echo "$s" | grep -q 'node-role.kubernetes.io/control-plane' && ! echo "$s" | grep -q 'kubernetes.io/hostname'"""),
|
||||
C("tolerates the control-plane taint, no nodeName in the manifest", 2, r"""
|
||||
$K -n default get pod cp-watch -o json 2>/dev/null | python3 -c '
|
||||
import sys,json; t=json.load(sys.stdin)["spec"].get("tolerations") or []
|
||||
sys.exit(0 if any("control-plane" in (x.get("key") or "") or (x.get("operator")=="Exists" and not x.get("key")) for x in t) else 1)' || exit 1
|
||||
$K -n default get pod cp-watch -o json --show-managed-fields | python3 -c '
|
||||
import sys,json
|
||||
mf=json.load(sys.stdin)["metadata"].get("managedFields",[])
|
||||
sys.exit(1 if any("f:nodeName" in json.dumps(m.get("fieldsV1",{})) for m in mf) else 0)'"""),
|
||||
],
|
||||
solution="""
|
||||
k get node {CP} --show-labels | tr ',' '\\n' | grep node-role # node-role.kubernetes.io/control-plane=
|
||||
k describe node {CP} | grep Taints # node-role.kubernetes.io/control-plane:NoSchedule
|
||||
k -n default run cp-watch --image=httpd:2-alpine $do > cp.yaml
|
||||
# rename the container to watch, then add at pod level:
|
||||
# nodeSelector:
|
||||
# node-role.kubernetes.io/control-plane: ""
|
||||
# tolerations:
|
||||
# - {key: node-role.kubernetes.io/control-plane, operator: Exists, effect: NoSchedule}
|
||||
k apply -f cp.yaml
|
||||
k -n default get pod cp-watch -o wide
|
||||
# The toleration only ALLOWS the control plane; the nodeSelector is what REQUIRES it. You need both.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 97 ----
|
||||
task(
|
||||
id=97, domain="TSHOOT", title="Stop the scheduler, schedule by hand", breaks="scheduler",
|
||||
task="""
|
||||
1. Stop the kube-scheduler **temporarily** — it runs as a static Pod on the control-plane node — and
|
||||
confirm it is gone.
|
||||
2. Create Pod `manual-1` (image `busybox:1.36`, command `sleep 86400`) in Namespace `default` and
|
||||
confirm it stays `Pending`.
|
||||
3. Schedule `manual-1` **by hand** onto the control-plane node and get it Running.
|
||||
4. Start the kube-scheduler again, then create Pod `manual-2` (same image and command) in `default`
|
||||
and confirm that the scheduler places it.
|
||||
""",
|
||||
setup=r"""
|
||||
# a pristine copy outside the static pod directory, so the pre-session baseline can put the
|
||||
# scheduler back if this task is abandoned half-done
|
||||
nx "$CP" 'mkdir -p /etc/kubernetes/.cka-sim && [ -f /etc/kubernetes/manifests/kube-scheduler.yaml ] && cp /etc/kubernetes/manifests/kube-scheduler.yaml /etc/kubernetes/.cka-sim/kube-scheduler.yaml' </dev/null
|
||||
$K -n default delete pod manual-1 manual-2 --ignore-not-found --force --grace-period=0 >/dev/null 2>&1
|
||||
exit 0
|
||||
""",
|
||||
checks=[
|
||||
C("kube-scheduler is back and Running", 2, r"""
|
||||
$K -n default get pod manual-1 >/dev/null 2>&1 || exit 1 # only once the task was actually done
|
||||
nx "$CP" 'test -f /etc/kubernetes/manifests/kube-scheduler.yaml' </dev/null || exit 1
|
||||
for i in $(seq 1 15); do [ "$(j -n kube-system pod -l component=kube-scheduler '{.items[0].status.containerStatuses[0].ready}')" = true ] && exit 0; sleep 4; done; exit 1"""),
|
||||
C("manual-1 Running on the control-plane node", 3, r"""
|
||||
eq "$(j -n default pod manual-1 '{.status.phase}')" Running && eq "$(j -n default pod manual-1 '{.spec.nodeName}')" "$CP" """),
|
||||
C("manual-2 Running, placed by the scheduler (no nodeName in its manifest)", 3, r"""
|
||||
eq "$(j -n default pod manual-2 '{.status.phase}')" Running && [ -n "$(j -n default pod manual-2 '{.spec.nodeName}')" ] || exit 1
|
||||
$K -n default get pod manual-2 -o json --show-managed-fields | python3 -c '
|
||||
import sys,json
|
||||
mf=json.load(sys.stdin)["metadata"].get("managedFields",[])
|
||||
sys.exit(1 if any("f:nodeName" in json.dumps(m.get("fieldsV1",{})) for m in mf) else 0)'"""),
|
||||
],
|
||||
solution="""
|
||||
{SHELL_CP}
|
||||
mv /etc/kubernetes/manifests/kube-scheduler.yaml /etc/kubernetes/ # the kubelet stops what it no longer sees
|
||||
exit
|
||||
k -n kube-system get pod -l component=kube-scheduler # gone (give it ~20s)
|
||||
k -n default run manual-1 --image=busybox:1.36 -- sleep 86400
|
||||
k -n default get pod manual-1 # Pending, no node
|
||||
# spec.nodeName is only settable on creation, so recreate it with the node filled in:
|
||||
k -n default get pod manual-1 -o yaml > m1.yaml
|
||||
# add under spec: nodeName: {CP}
|
||||
k replace --force -f m1.yaml
|
||||
k -n default get pod manual-1 -o wide # Running on {CP}
|
||||
{SHELL_CP}
|
||||
mv /etc/kubernetes/kube-scheduler.yaml /etc/kubernetes/manifests/
|
||||
exit
|
||||
k -n kube-system get pod -l component=kube-scheduler -w # Running again
|
||||
k -n default run manual-2 --image=busybox:1.36 -- sleep 86400
|
||||
k -n default get pod manual-2 -o wide # a worker, chosen by the scheduler
|
||||
# nodeName skips the scheduler entirely - including its taint checks, which is why manual-1 can
|
||||
# sit on the tainted control plane. (The Binding API does the same without recreating the pod.)
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 98 ----
|
||||
_CHART = r'''
|
||||
mkchart() { # dir name version image replicas
|
||||
mkdir -p "$1/templates"
|
||||
printf 'apiVersion: v2\nname: %s\nversion: %s\nappVersion: "%s"\n' "$2" "$3" "${4##*:}" > "$1/Chart.yaml"
|
||||
printf 'replicaCount: %s\nimage: %s\n' "$5" "$4" > "$1/values.yaml"
|
||||
cat > "$1/templates/deployment.yaml" <<'YAML'
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: {{ .Release.Name }}, labels: {app: {{ .Release.Name }}}}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector: {matchLabels: {app: {{ .Release.Name }}}}
|
||||
template:
|
||||
metadata: {labels: {app: {{ .Release.Name }}}}
|
||||
spec:
|
||||
containers: [{name: main, image: "{{ .Values.image }}"}]
|
||||
YAML
|
||||
}
|
||||
'''
|
||||
|
||||
task(
|
||||
id=98, domain="ARCH", title="Helm release housekeeping",
|
||||
task="""
|
||||
Namespace `oslo-helm` holds several Helm releases; local charts are under `{WS}/q98/charts`.
|
||||
|
||||
1. Delete the release `web-legacy`.
|
||||
2. Upgrade the release `web-main` to chart version **1.1.0** (`{WS}/q98/charts/nginx-lite-1.1.0`).
|
||||
3. Install a new release `cache` from `{WS}/q98/charts/redis-lite` with **2** replicas (the chart's
|
||||
`replicaCount` value).
|
||||
4. One release in the Namespace never finished installing. Find it and remove it.
|
||||
""",
|
||||
setup=NS + _CHART + r"""
|
||||
mkns oslo-helm
|
||||
C="$WS/q98/charts"; rm -rf "$WS/q98"; mkdir -p "$C"
|
||||
mkchart "$C/nginx-lite-1.0.0" nginx-lite 1.0.0 nginx:1.25 1
|
||||
mkchart "$C/nginx-lite-1.1.0" nginx-lite 1.1.0 nginx:1.27 1
|
||||
mkchart "$C/redis-lite" redis-lite 0.3.0 redis:7-alpine 1
|
||||
mkchart /tmp/q98-stuck cache-edge 0.1.0 busybox:0.0-does-not-exist 1
|
||||
for r in $(helm -n oslo-helm list -q 2>/dev/null); do helm -n oslo-helm uninstall "$r" >/dev/null 2>&1; done
|
||||
helm -n oslo-helm install web-legacy "$C/nginx-lite-1.0.0" >/dev/null
|
||||
helm -n oslo-helm install web-main "$C/nginx-lite-1.0.0" >/dev/null
|
||||
# helm records the release as pending-install BEFORE it starts waiting; kill it mid-wait and it
|
||||
# stays that way - the classic result of an interrupted install or a CI job that timed out
|
||||
timeout -s KILL 6 helm -n oslo-helm install cache-broken /tmp/q98-stuck --wait --timeout 120s >/dev/null 2>&1
|
||||
rm -rf /tmp/q98-stuck
|
||||
helm -n oslo-helm status cache-broken -o json 2>/dev/null | grep -q '"status":"pending-install"' || echo "warn: cache-broken did not end up pending-install"
|
||||
""",
|
||||
checks=[
|
||||
C("web-legacy is gone", 1, r"""! helm -n oslo-helm status web-legacy >/dev/null 2>&1 && helm -n oslo-helm status web-main >/dev/null 2>&1"""),
|
||||
C("web-main deployed at chart version 1.1.0", 2, r"""
|
||||
helm -n oslo-helm list -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
r={x["name"]:x for x in json.load(sys.stdin)}.get("web-main") or {}
|
||||
sys.exit(0 if r.get("chart")=="nginx-lite-1.1.0" and r.get("status")=="deployed" and int(r.get("revision",0))>=2 else 1)'"""),
|
||||
C("cache deployed from redis-lite with 2 available replicas", 2, r"""
|
||||
helm -n oslo-helm list -o json 2>/dev/null | python3 -c '
|
||||
import sys,json
|
||||
r={x["name"]:x for x in json.load(sys.stdin)}.get("cache") or {}
|
||||
sys.exit(0 if r.get("chart","").startswith("redis-lite-") and r.get("status")=="deployed" else 1)' || exit 1
|
||||
for i in $(seq 1 20); do [ "$(j -n oslo-helm deploy cache '{.status.availableReplicas}')" = 2 ] && exit 0; sleep 3; done; exit 1"""),
|
||||
C("the stuck release is removed; nothing pending remains", 2, r"""
|
||||
! helm -n oslo-helm status cache-broken >/dev/null 2>&1 && [ -z "$(helm -n oslo-helm list --pending -q 2>/dev/null)" ] && ! $K -n oslo-helm get deploy cache-broken >/dev/null 2>&1"""),
|
||||
],
|
||||
solution="""
|
||||
helm -n oslo-helm list # Helm 4 shows every status; Helm 3 hides pending ones
|
||||
helm -n oslo-helm list --pending # works in both: cache-broken pending-install
|
||||
helm -n oslo-helm uninstall web-legacy
|
||||
helm -n oslo-helm upgrade web-main {WS}/q98/charts/nginx-lite-1.1.0
|
||||
helm -n oslo-helm install cache {WS}/q98/charts/redis-lite --set replicaCount=2
|
||||
helm -n oslo-helm uninstall cache-broken # also removes the Deployment it half-created
|
||||
helm -n oslo-helm list # web-main (1.1.0, rev 2) and cache, both deployed
|
||||
# A pending-install release blocks a plain re-install under the same name ("cannot re-use a name
|
||||
# that is still in use"). `helm history <rel>` shows when it got stuck; uninstall clears it.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 99 ----
|
||||
task(
|
||||
id=99, domain="STOR", title="Per-replica storage for a StatefulSet",
|
||||
task="""
|
||||
In Namespace `lima-data`:
|
||||
|
||||
1. Create a headless Service `ledger-db` (port 5432) and a StatefulSet `ledger-db` with **3**
|
||||
replicas: image `busybox:1.36`, command `sh -c 'hostname > /data/id.txt && sleep 86400'`, and a
|
||||
`volumeClaimTemplates` entry `data` — 100Mi, ReadWriteOnce, StorageClass `standard` — mounted at
|
||||
`/data`.
|
||||
2. Once all 3 are Ready, scale the StatefulSet down to **2**.
|
||||
3. Write the names of the PersistentVolumeClaims that belong to the StatefulSet and still exist
|
||||
after the scale-down to `{WS}/q99/pvcs.txt`, one per line, sorted.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns lima-data
|
||||
rm -rf "$WS/q99"; mkdir -p "$WS/q99"
|
||||
$K -n lima-data delete sts ledger-db --ignore-not-found --wait=true >/dev/null 2>&1
|
||||
$K -n lima-data delete svc ledger-db --ignore-not-found >/dev/null
|
||||
$K -n lima-data delete pvc -l app=ledger-db --ignore-not-found >/dev/null 2>&1
|
||||
for i in 0 1 2 3 4; do $K -n lima-data delete pvc data-ledger-db-$i --ignore-not-found --wait=false >/dev/null 2>&1; done
|
||||
""",
|
||||
checks=[
|
||||
C("headless Service and a StatefulSet with the right claim template", 2, r"""
|
||||
eq "$(j -n lima-data svc ledger-db '{.spec.clusterIP}')" None && eq "$(j -n lima-data sts ledger-db '{.spec.serviceName}')" ledger-db || exit 1
|
||||
t='{.spec.volumeClaimTemplates[?(@.metadata.name=="data")]'
|
||||
eq "$(j -n lima-data sts ledger-db "$t.spec.resources.requests.storage}")" 100Mi && eq "$(j -n lima-data sts ledger-db "$t.spec.storageClassName}")" standard \
|
||||
&& j -n lima-data sts ledger-db "$t.spec.accessModes}" | grep -q ReadWriteOnce"""),
|
||||
C("scaled to 2, both Ready", 2, r"""eq "$(j -n lima-data sts ledger-db '{.spec.replicas}')" 2 && eq "$(j -n lima-data sts ledger-db '{.status.readyReplicas}')" 2"""),
|
||||
C("each replica wrote its own identity to its own volume", 2, r"""
|
||||
for i in 0 1; do eq "$($K -n lima-data exec ledger-db-$i -- cat /data/id.txt 2>/dev/null | tr -d '[:space:]')" "ledger-db-$i" || exit 1; done
|
||||
[ "$(j -n lima-data pod ledger-db-0 '{.spec.volumes[?(@.name=="data")].persistentVolumeClaim.claimName}')" = data-ledger-db-0 ]"""),
|
||||
C("pvcs.txt lists every claim that survived the scale-down", 2, r"""
|
||||
want=$($K -n lima-data get pvc --no-headers -o custom-columns=N:.metadata.name 2>/dev/null | grep '^data-ledger-db-' | sort)
|
||||
[ -n "$want" ] && [ "$(echo "$want" | wc -l)" -ge 3 ] && [ "$(grep . "$WS/q99/pvcs.txt" 2>/dev/null)" = "$want" ]"""),
|
||||
],
|
||||
solution="""
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: ledger-db, namespace: lima-data}
|
||||
spec: {clusterIP: None, selector: {app: ledger-db}, ports: [{port: 5432}]}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata: {name: ledger-db, namespace: lima-data}
|
||||
spec:
|
||||
serviceName: ledger-db
|
||||
replicas: 3
|
||||
selector: {matchLabels: {app: ledger-db}}
|
||||
template:
|
||||
metadata: {labels: {app: ledger-db}}
|
||||
spec:
|
||||
containers:
|
||||
- name: db
|
||||
image: busybox:1.36
|
||||
command: [sh, -c, "hostname > /data/id.txt && sleep 86400"]
|
||||
volumeMounts: [{name: data, mountPath: /data}]
|
||||
volumeClaimTemplates:
|
||||
- metadata: {name: data}
|
||||
spec: {accessModes: [ReadWriteOnce], storageClassName: standard, resources: {requests: {storage: 100Mi}}}
|
||||
EOF
|
||||
k -n lima-data rollout status sts ledger-db
|
||||
k -n lima-data scale sts ledger-db --replicas=2
|
||||
k -n lima-data get pvc -o custom-columns=N:.metadata.name --no-headers | grep '^data-ledger-db-' | sort > {WS}/q99/pvcs.txt
|
||||
cat {WS}/q99/pvcs.txt # all THREE: data-ledger-db-0, -1, -2
|
||||
# Claims are named <template>-<statefulset>-<ordinal> and outlive their pods: scaling down (or
|
||||
# deleting the StatefulSet) keeps them unless spec.persistentVolumeClaimRetentionPolicy says
|
||||
# otherwise, so scaling back up re-attaches ledger-db-2 to its old data.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 100 ----
|
||||
task(
|
||||
id=100, domain="NET", title="Namespace AND pod selector, enforced",
|
||||
task="""
|
||||
Pods labelled `app=db` in Namespace `oslo-db` must accept connections on TCP port 80 **only** from
|
||||
Pods labelled `role=backend` that run in Namespaces labelled `env=prod`. Every other source must be
|
||||
refused. Do not restrict egress.
|
||||
|
||||
Create the NetworkPolicy `db-ingress` in `oslo-db`. This cluster's CNI enforces NetworkPolicy, so
|
||||
test what you built: the `api` Pods in `oslo-api` must get through to Service `db`, the `debug`
|
||||
Pods in `oslo-api` must not, and neither must the `api` Pods in `lima-api`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns oslo-db; mkns oslo-api; mkns lima-api
|
||||
$K label ns oslo-api env=prod --overwrite >/dev/null; $K label ns lima-api env=dev --overwrite >/dev/null
|
||||
$K -n oslo-db delete netpol --all >/dev/null 2>&1
|
||||
$K -n oslo-db create deploy db --image=nginx:1.27 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n oslo-db expose deploy db --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
mk() { # ns name role
|
||||
cat <<EOF | $K apply -f - >/dev/null
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: $2, namespace: $1}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: $2}}
|
||||
template:
|
||||
metadata: {labels: {app: $2, role: $3}}
|
||||
spec: {containers: [{name: c, image: busybox:1.36, command: [sleep, "86400"]}]}
|
||||
EOF
|
||||
}
|
||||
mk oslo-api api backend; mk oslo-api debug debug; mk lima-api api backend
|
||||
for d in oslo-db/db oslo-api/api oslo-api/debug lima-api/api; do $K -n ${d%%/*} rollout status deploy ${d##*/} --timeout=120s >/dev/null 2>&1; done
|
||||
""",
|
||||
checks=[
|
||||
C("selects app=db, Ingress only", 1, r"""
|
||||
n="$K -n oslo-db get netpol db-ingress"
|
||||
eq "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" db && eq "$($n -o jsonpath='{.spec.policyTypes}')" '["Ingress"]'"""),
|
||||
C("one peer holding BOTH namespaceSelector env=prod AND podSelector role=backend, on TCP 80", 3, r"""
|
||||
$K -n oslo-db get netpol db-ingress -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
|
||||
if ok:
|
||||
f=ing[0]["from"][0]
|
||||
ok=f.get("namespaceSelector",{}).get("matchLabels",{}).get("env")=="prod" and f.get("podSelector",{}).get("matchLabels",{}).get("role")=="backend"
|
||||
ok=ok and [(str(p.get("port")),p.get("protocol","TCP")) for p in ing[0].get("ports",[])]==[("80","TCP")]
|
||||
sys.exit(0 if ok else 1)'"""),
|
||||
C("oslo-api/api reaches db", 2, r"""
|
||||
$K -n oslo-db get netpol db-ingress >/dev/null 2>&1 || exit 1 # no policy yet: nothing earned
|
||||
for i in 1 2 3 4 5; do $K -n oslo-api exec deploy/api -- wget -qO- -T3 http://db.oslo-db >/dev/null 2>&1 && exit 0; sleep 2; done; exit 1"""),
|
||||
C("oslo-api/debug and lima-api/api are refused", 2, r"""
|
||||
$K -n oslo-db get netpol db-ingress >/dev/null 2>&1 || exit 1
|
||||
! $K -n oslo-api exec deploy/debug -- wget -qO- -T3 http://db.oslo-db >/dev/null 2>&1 \
|
||||
&& ! $K -n lima-api exec deploy/api -- wget -qO- -T3 http://db.oslo-db >/dev/null 2>&1"""),
|
||||
],
|
||||
solution="""
|
||||
k get ns --show-labels | grep -E 'oslo-api|lima-api' # env=prod / env=dev
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata: {name: db-ingress, namespace: oslo-db}
|
||||
spec:
|
||||
podSelector: {matchLabels: {app: db}}
|
||||
policyTypes: [Ingress]
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector: {matchLabels: {env: prod}}
|
||||
podSelector: {matchLabels: {role: backend}} # SAME list item = AND
|
||||
ports: [{protocol: TCP, port: 80}]
|
||||
EOF
|
||||
k -n oslo-api exec deploy/api -- wget -qO- -T3 http://db.oslo-db | head -1 # <!DOCTYPE html>
|
||||
k -n oslo-api exec deploy/debug -- wget -qO- -T3 http://db.oslo-db # download timed out
|
||||
k -n lima-api exec deploy/api -- wget -qO- -T3 http://db.oslo-db # download timed out
|
||||
# The trap is one dash: "- namespaceSelector: ...\\n - podSelector: ..." (two list items) means OR -
|
||||
# any pod in a prod namespace, OR a backend pod in oslo-db's own namespace. One item with both
|
||||
# selectors means AND.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 101 ----
|
||||
task(
|
||||
id=101, domain="NET", title="Resolves everything except the cluster",
|
||||
task="""
|
||||
Deployment `reporter` in Namespace `lima-client` cannot reach Service `ledger` — its log says
|
||||
`bad address`. The Service, its endpoints and cluster DNS are all fine: other Pods in the Namespace
|
||||
resolve `ledger` without trouble.
|
||||
|
||||
Fix the `reporter` Deployment (not the Service), without changing its image or command, so that its
|
||||
requests succeed. Write the field you changed and its new value, as `<field>=<value>`, to
|
||||
`{WS}/q101/fix.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns lima-client
|
||||
rm -rf "$WS/q101"; mkdir -p "$WS/q101"
|
||||
$K -n lima-client create deploy ledger --image=nginx:1.27 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n lima-client expose deploy ledger --port=8080 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
||||
$K -n lima-client delete deploy reporter --ignore-not-found --wait=true >/dev/null 2>&1
|
||||
$K apply -f - >/dev/null <<'EOF'
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: reporter, namespace: lima-client}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: reporter}}
|
||||
template:
|
||||
metadata: {labels: {app: reporter}}
|
||||
spec:
|
||||
dnsPolicy: Default
|
||||
containers:
|
||||
- name: reporter
|
||||
image: busybox:1.36
|
||||
command: [sh, -c, 'while true; do if wget -qO- -T2 http://ledger:8080/ >/dev/null 2>&1; then echo "$(date +%T) ledger ok"; else wget -qO- -T2 http://ledger:8080/ 2>&1 | head -1; fi; sleep 3; done']
|
||||
EOF
|
||||
$K -n lima-client rollout status deploy ledger --timeout=120s >/dev/null 2>&1
|
||||
""",
|
||||
checks=[
|
||||
C("reporter uses the cluster DNS policy again", 3, r"""
|
||||
p=$(j -n lima-client deploy reporter '{.spec.template.spec.dnsPolicy}'); [ -z "$p" ] || [ "$p" = ClusterFirst ] || [ "$p" = ClusterFirstWithHostNet ]"""),
|
||||
C("reporter's requests now succeed", 3, r"""
|
||||
for i in $(seq 1 15); do
|
||||
p=$($K -n lima-client get pod -l app=reporter --field-selector status.phase=Running -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
|
||||
[ -n "$p" ] && $K -n lima-client logs "$p" --tail=3 2>/dev/null | grep -q 'ledger ok' && exit 0; sleep 3
|
||||
done; exit 1"""),
|
||||
C("fix.txt names dnsPolicy and its new value", 1, r"""grep -Eqi '^ *dnsPolicy *= *ClusterFirst *$' "$WS/q101/fix.txt" 2>/dev/null"""),
|
||||
],
|
||||
solution="""
|
||||
k -n lima-client logs deploy/reporter --tail=3 # wget: bad address 'ledger:8080'
|
||||
k -n lima-client get endpointslices # the Service has endpoints - not the problem
|
||||
k -n lima-client get deploy reporter -o yaml | grep -n dns
|
||||
# dnsPolicy: Default <- "Default" means: use the NODE's resolver, NOT cluster DNS
|
||||
k -n lima-client exec deploy/reporter -- cat /etc/resolv.conf # the podman/host resolver, no cluster search domains
|
||||
k -n lima-client patch deploy reporter -p '{"spec":{"template":{"spec":{"dnsPolicy":"ClusterFirst"}}}}'
|
||||
k -n lima-client logs deploy/reporter -f # "ledger ok"
|
||||
echo dnsPolicy=ClusterFirst > {WS}/q101/fix.txt
|
||||
# The trap is the name: the DEFAULT dnsPolicy is ClusterFirst, while the policy literally called
|
||||
# "Default" inherits the node's /etc/resolv.conf. ClusterFirstWithHostNet is for hostNetwork pods.
|
||||
""",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ 102 ----
|
||||
task(
|
||||
id=102, domain="STOR", title="A claim that can never bind",
|
||||
task="""
|
||||
Deployment `uploader` in Namespace `oslo-files` has no running Pod. Find out why and fix it, so its
|
||||
Pod runs with the claim mounted at `/files`. Keep the claim's name (`shared-files`), its size and its
|
||||
StorageClass. Write the root cause, in one line, to `{WS}/q102/reason.txt`.
|
||||
""",
|
||||
setup=NS + r"""
|
||||
mkns oslo-files
|
||||
rm -rf "$WS/q102"; mkdir -p "$WS/q102"
|
||||
$K -n oslo-files delete deploy uploader --ignore-not-found --wait=true >/dev/null 2>&1
|
||||
$K -n oslo-files delete pvc shared-files --ignore-not-found --wait=true >/dev/null 2>&1
|
||||
$K apply -f - >/dev/null <<'EOF'
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata: {name: shared-files, namespace: oslo-files}
|
||||
spec: {accessModes: [ReadWriteMany], storageClassName: standard, resources: {requests: {storage: 1Gi}}}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: uploader, namespace: oslo-files}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector: {matchLabels: {app: uploader}}
|
||||
template:
|
||||
metadata: {labels: {app: uploader}}
|
||||
spec:
|
||||
volumes: [{name: files, persistentVolumeClaim: {claimName: shared-files}}]
|
||||
containers: [{name: up, image: busybox:1.36, command: [sleep, "86400"], volumeMounts: [{name: files, mountPath: /files}]}]
|
||||
EOF
|
||||
""",
|
||||
checks=[
|
||||
C("shared-files Bound: 1Gi, class standard, an access mode local-path supports", 3, r"""
|
||||
eq "$(j -n oslo-files pvc shared-files '{.status.phase}')" Bound && eq "$(j -n oslo-files pvc shared-files '{.spec.resources.requests.storage}')" 1Gi \
|
||||
&& eq "$(j -n oslo-files pvc shared-files '{.spec.storageClassName}')" standard \
|
||||
&& ! j -n oslo-files pvc shared-files '{.spec.accessModes}' | grep -q ReadWriteMany"""),
|
||||
C("uploader available with the claim at /files", 2, r"""
|
||||
for i in $(seq 1 20); do [ "$(j -n oslo-files deploy uploader '{.status.availableReplicas}')" = 1 ] && break; sleep 3; done
|
||||
eq "$(j -n oslo-files deploy uploader '{.status.availableReplicas}')" 1 \
|
||||
&& eq "$(j -n oslo-files deploy uploader '{.spec.template.spec.volumes[?(@.persistentVolumeClaim.claimName=="shared-files")].name}')" files \
|
||||
&& j -n oslo-files deploy uploader '{.spec.template.spec.containers[0].volumeMounts[*].mountPath}' | grep -qw /files"""),
|
||||
C("reason.txt names the unsupported access mode", 2, r"""grep -Eqi 'ReadWriteMany|RWX|access ?mode' "$WS/q102/reason.txt" 2>/dev/null"""),
|
||||
],
|
||||
solution="""
|
||||
k -n oslo-files get pod # Pending
|
||||
k -n oslo-files describe pod -l app=uploader | tail -4 # unbound immediate PersistentVolumeClaims / waiting
|
||||
k -n oslo-files describe pvc shared-files | tail -4
|
||||
# ProvisioningFailed ... NodePath only supports ReadWriteOnce and ReadWriteOncePod (1.22+) access modes
|
||||
# accessModes is immutable on a PVC, so recreate it. The Pending pod was never scheduled, so it
|
||||
# does not hold the claim (pvc-protection only counts scheduled pods) and the delete goes through.
|
||||
k -n oslo-files get pvc shared-files -o yaml > pvc.yaml # change ReadWriteMany -> ReadWriteOnce, strip status/uid
|
||||
k -n oslo-files delete pvc shared-files
|
||||
k apply -f pvc.yaml
|
||||
k -n oslo-files rollout restart deploy uploader # or just wait - the pending pod retries
|
||||
k -n oslo-files get pvc,pod
|
||||
echo 'PVC requested ReadWriteMany, which the local-path provisioner cannot provide (RWO/RWOP only)' > {WS}/q102/reason.txt
|
||||
# RWX needs a filesystem many nodes can mount at once (NFS, CephFS, a cloud file service).
|
||||
# ReadWriteOnce is per NODE, not per pod - ReadWriteOncePod is the per-pod one.
|
||||
""",
|
||||
)
|
||||
|
||||
QUESTIONS = Q
|
||||
@ -6,7 +6,7 @@ mkns() {
|
||||
# Wait for the namespace's `default` ServiceAccount before returning. The controller creates it
|
||||
# asynchronously, and a Pod applied in the gap is rejected outright:
|
||||
# error looking up service account <ns>/default: serviceaccount "default" not found
|
||||
# Setups that create a namespace and immediately apply Pods lose that race under load,
|
||||
# Setups that create a namespace and immediately apply Pods lose that race under load (Q204),
|
||||
# which fails the seed and costs the candidate a whole task. It belongs here, not in each task.
|
||||
for _ in $(seq 1 30); do $K -n "$1" get sa default >/dev/null 2>&1 && return 0; sleep 1; done
|
||||
}
|
||||
|
||||
@ -23,9 +23,8 @@ TRACK = {
|
||||
# tasks per domain drawn into a session (weights follow the exam blueprint)
|
||||
"quota": {"ARCH": 5, "WORK": 4, "NET": 4, "STOR": 2, "TSHOOT": 5}, # 20 tasks
|
||||
"quota_hard": {"ARCH": 6, "WORK": 5, "NET": 5, "STOR": 3, "TSHOOT": 6}, # 25 tasks, hard ones preferred
|
||||
# fixed simulator sets (./exam sim c|d) - excluded from random sampling. Sets A and B were
|
||||
# retired on 2026-09-22; their last (fixed, verified) versions are in git history.
|
||||
"sets": {"c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"},
|
||||
# fixed simulator sets (./exam sim a|b|c) - excluded from random sampling
|
||||
"sets": {"a": "Simulator Set A", "b": "Simulator Set B", "c": "Simulator Set C"},
|
||||
"domains": {
|
||||
"ARCH": "Cluster Architecture, Installation & Configuration (25%)",
|
||||
"WORK": "Workloads & Scheduling (15%)",
|
||||
@ -178,7 +177,7 @@ $K delete ns store-prod --ignore-not-found --wait=false >/dev/null
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 5, "domain": "ARCH", "title": "Static pod", "breaks": ["w3-staticpath"], # Q46 relocates W3's staticPodPath
|
||||
"id": 5, "domain": "ARCH", "title": "Static pod",
|
||||
"task": """
|
||||
Create a **static pod** named `static-web` running image `nginx:1.27`
|
||||
on node `{W3}` (namespace `default`).
|
||||
@ -363,8 +362,8 @@ Create a NetworkPolicy `backend-policy` in `netpol` so that pods with `app=backe
|
||||
on TCP port **8080**,
|
||||
- reject all other ingress.
|
||||
|
||||
Do not restrict egress. (This cluster's kindnet enforces NetworkPolicy, so a wrong policy really
|
||||
blocks traffic; you are graded on the policy spec.)
|
||||
Do not restrict egress. (The default kind CNI does not enforce policies;
|
||||
you are graded on the policy spec.)
|
||||
""",
|
||||
"setup": NS + r"""
|
||||
mkns netpol
|
||||
@ -631,11 +630,11 @@ $K -n default delete pod sched-probe --wait=false >/dev/null 2>&1
|
||||
},
|
||||
]
|
||||
|
||||
# ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 44-67 (hard) in bank_hard,
|
||||
# ---- 68-85 / 86-102 in the two fixed simulator sets. Numbering is contiguous - keep it that way.
|
||||
from tracks.cka import bank_extra, bank_hard, bank_sim_c, bank_sim_d
|
||||
# ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 101-124 (hard) in bank_hard,
|
||||
# ---- 201-217 / 301-317 / 401-418 in the three fixed simulator sets
|
||||
from tracks.cka import bank_extra, bank_hard, bank_sim_a, bank_sim_b, bank_sim_c
|
||||
QUESTIONS = (QUESTIONS + bank_extra.QUESTIONS + bank_hard.QUESTIONS
|
||||
+ bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS)
|
||||
+ bank_sim_a.QUESTIONS + bank_sim_b.QUESTIONS + bank_sim_c.QUESTIONS)
|
||||
QUESTIONS.sort(key=lambda q: q["id"])
|
||||
_ids = [q["id"] for q in QUESTIONS]
|
||||
assert len(_ids) == len(set(_ids)), "duplicate question ids"
|
||||
|
||||
@ -245,7 +245,7 @@ k -n kube-system get pod -l component=etcd -o jsonpath='{.items[0].spec.containe
|
||||
"""
|
||||
|
||||
# Hard Architecture Tasks
|
||||
S[44] = """# Step 1: Inspect bindings for the ServiceAccount to find and remove over-privileged bindings
|
||||
S[101] = """# Step 1: Inspect bindings for the ServiceAccount to find and remove over-privileged bindings
|
||||
k get clusterrolebinding -o wide | grep %{sa}
|
||||
k delete clusterrolebinding %{sa}-legacy-admin
|
||||
|
||||
@ -262,13 +262,11 @@ k -n %{ns} create rolebinding %{sa}-rb --role=%{sa}-role --serviceaccount=%{ns}:
|
||||
k create clusterrole %{sa}-nodes --verb=list --resource=nodes
|
||||
k create clusterrolebinding %{sa}-crb --clusterrole=%{sa}-nodes --serviceaccount=%{ns}:%{sa}
|
||||
|
||||
# Step 6: Verify effective permissions. For a SUBRESOURCE use --subresource: `can-i create
|
||||
# pods/exec` means "create the pod named exec" and says no even when the Role is right.
|
||||
# Step 6: Verify effective permissions
|
||||
k auth can-i --list -n %{ns} --as=system:serviceaccount:%{ns}:%{sa}
|
||||
k auth can-i create pods --subresource=exec -n %{ns} --as=system:serviceaccount:%{ns}:%{sa} # yes
|
||||
"""
|
||||
|
||||
S[45] = """# Step 1: Access the control plane node
|
||||
S[102] = """# Step 1: Access the control plane node
|
||||
{SHELL_CP}
|
||||
|
||||
# Step 2: Restore snapshot into a new data directory
|
||||
@ -288,7 +286,7 @@ watch crictl ps
|
||||
kubectl -n default get cm %{cm}
|
||||
"""
|
||||
|
||||
S[46] = """# Step 1: Identify the edge node matching the label selector
|
||||
S[103] = """# Step 1: Identify the edge node matching the label selector
|
||||
k get nodes -l tier=edge
|
||||
|
||||
# Step 2: Access the target node
|
||||
@ -313,7 +311,7 @@ EOF
|
||||
# k get pods -n %{ns} -o wide
|
||||
"""
|
||||
|
||||
S[47] = """# Step 1: Generate user private key and Certificate Signing Request
|
||||
S[104] = """# Step 1: Generate user private key and Certificate Signing Request
|
||||
openssl genrsa -out %{user}.key 2048
|
||||
openssl req -new -key %{user}.key -subj "/CN=%{user}/O=developers" -out %{user}.csr
|
||||
|
||||
@ -331,7 +329,7 @@ k get csr %{user} -o jsonpath='{.status.certificate}' | base64 -d > %{user}.crt
|
||||
k -n %{ns} create rolebinding %{user}-edit --clusterrole=edit --user=%{user}
|
||||
|
||||
# Step 4: Construct self-contained kubeconfig with embedded certificates
|
||||
KC={WS}/q47/%{user}.kubeconfig
|
||||
KC={WS}/q104/%{user}.kubeconfig
|
||||
k config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt
|
||||
SERVER=$(k config view --minify -o jsonpath='{.clusters[0].cluster.server}')
|
||||
|
||||
@ -344,8 +342,8 @@ kubectl --kubeconfig $KC config use-context %{user}@lab
|
||||
kubectl --kubeconfig $KC get pods
|
||||
"""
|
||||
|
||||
S[48] = """# Step 1: Create directory for prod overlay
|
||||
mkdir -p {WS}/q48/overlays/prod && cd {WS}/q48/overlays/prod
|
||||
S[105] = """# Step 1: Create directory for prod overlay
|
||||
mkdir -p {WS}/q105/overlays/prod && cd {WS}/q105/overlays/prod
|
||||
|
||||
# Step 2: Create replica patch manifest
|
||||
cat > replicas.yaml <<EOF
|
||||
@ -383,10 +381,7 @@ k create ns %{ns}
|
||||
k apply -k .
|
||||
"""
|
||||
|
||||
S[49] = """# Step 0: This context does not default to the `default` namespace - check, then pass -n default
|
||||
k config view --minify -o jsonpath='{..namespace}{"\\n"}'
|
||||
|
||||
# Step 1: CRD with validation (required fields, integer bounds, enum) and a server-side default
|
||||
S[106] = """# Step 1: Create CRD with validation rules (required fields, enum, integer bounds)
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
@ -394,11 +389,7 @@ metadata: {name: $(echo %{kind} | tr A-Z a-z)s.deploy.example.com}
|
||||
spec:
|
||||
group: deploy.example.com
|
||||
scope: Namespaced
|
||||
names:
|
||||
kind: %{kind}
|
||||
plural: $(echo %{kind} | tr A-Z a-z)s
|
||||
singular: $(echo %{kind} | tr A-Z a-z)
|
||||
shortNames: [$(echo %{kind} | tr A-Z a-z | cut -c1-2)]
|
||||
names: {kind: %{kind}, plural: $(echo %{kind} | tr A-Z a-z)s, singular: $(echo %{kind} | tr A-Z a-z)}
|
||||
versions:
|
||||
- name: v1
|
||||
served: true
|
||||
@ -409,31 +400,29 @@ spec:
|
||||
properties:
|
||||
spec:
|
||||
type: object
|
||||
required: [replicas, image] # sits NEXT TO properties, not inside a field
|
||||
required: [replicas, image]
|
||||
properties:
|
||||
replicas: {type: integer, minimum: 1, maximum: 10}
|
||||
environment: {type: string, enum: [prod, stage, dev], default: dev}
|
||||
image: {type: string}
|
||||
EOF
|
||||
|
||||
# Step 2: A valid object
|
||||
k -n default apply -f - <<EOF
|
||||
# Step 2: Apply valid custom resource
|
||||
k apply -f - <<EOF
|
||||
apiVersion: deploy.example.com/v1
|
||||
kind: %{kind}
|
||||
metadata: {name: good}
|
||||
spec: {replicas: 3, image: nginx:1.27, environment: %{env}}
|
||||
EOF
|
||||
|
||||
# Step 3: The rejection goes to STDERR - redirect 2>, not >, or the file stays empty
|
||||
printf 'apiVersion: deploy.example.com/v1\\nkind: %{kind}\\nmetadata: {name: bad}\\nspec: {replicas: 0, image: x}\\n' | k -n default apply -f - 2> {WS}/q49/rejected.txt
|
||||
cat {WS}/q49/rejected.txt # ...spec.replicas in body should be greater than or equal to 1
|
||||
# Step 3: Test schema rejection of invalid resource (0 replicas)
|
||||
printf 'apiVersion: deploy.example.com/v1\\nkind: %{kind}\\nmetadata: {name: bad}\\nspec: {replicas: 0, image: x}\\n' | k apply -f - 2> {WS}/q106/rejected.txt
|
||||
|
||||
# Step 4: Omit environment and let the API server fill in the default
|
||||
printf 'apiVersion: deploy.example.com/v1\\nkind: %{kind}\\nmetadata: {name: defaulted}\\nspec: {replicas: 2, image: nginx:1.27}\\n' | k -n default apply -f -
|
||||
k -n default get $(echo %{kind} | tr A-Z a-z) defaulted -o jsonpath='{.spec.environment}{"\\n"}' # dev
|
||||
# Step 4: Verify default values on omitted fields
|
||||
printf 'apiVersion: deploy.example.com/v1\\nkind: %{kind}\\nmetadata: {name: defaulted}\\nspec: {replicas: 2, image: nginx:1.27}\\n' | k apply -f -
|
||||
"""
|
||||
|
||||
S[65] = """# Step 1: Access control plane node
|
||||
S[122] = """# Step 1: Access control plane node
|
||||
{SHELL_CP}
|
||||
|
||||
# Step 2: Renew all cluster certificates via kubeadm
|
||||
@ -448,10 +437,10 @@ exit
|
||||
|
||||
# Step 5: Back on your own machine now - {WS} is a path on your machine, not the node,
|
||||
# so capture the expiry report from out here and redirect it into the required file
|
||||
podman exec cka-sim-control-plane kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q65/expiry.txt
|
||||
podman exec cka-sim-control-plane kubeadm certs check-expiration | grep '^apiserver ' > {WS}/q122/expiry.txt
|
||||
"""
|
||||
|
||||
S[67] = """# Step 1: On the control-plane node, create a fresh bootstrap token
|
||||
S[124] = """# Step 1: On the control-plane node, create a fresh bootstrap token
|
||||
{SHELL_CP}
|
||||
kubeadm token create --ttl 2h
|
||||
# -> prints a token like abcdef.0123456789abcdef
|
||||
@ -470,7 +459,7 @@ exit
|
||||
kubectl get endpointslices -l kubernetes.io/service-name=kubernetes -o jsonpath='{.items[0].endpoints[0].addresses[0]}:{.items[0].ports[0].port}{"\\n"}'
|
||||
|
||||
# Step 4: Assemble the handoff file
|
||||
cat > {WS}/q67/node-join.env <<EOF
|
||||
cat > {WS}/q124/node-join.env <<EOF
|
||||
JOIN_TOKEN=<token from step 1>
|
||||
CA_HASH=sha256:<hash from step 2>
|
||||
API_ENDPOINT=<ip:port from step 3>
|
||||
@ -793,50 +782,43 @@ k -n %{ns} get pod -l app=worker -o jsonpath='{.items[0].spec.containers[0].reso
|
||||
"""
|
||||
|
||||
# Hard Workload Tasks
|
||||
S[50] = """# Step 1: Read the constraints before writing anything
|
||||
k get node {W1} -o jsonpath='{.spec.taints}{"\\n"}' # %{key}=%{val}:NoSchedule
|
||||
k -n %{ns} describe quota # pods: 2, requests.cpu: 250m
|
||||
S[107] = """# Step 1: Inspect namespace quota constraints
|
||||
k -n %{ns} get quota
|
||||
|
||||
# Step 2: The quota is the trap twice over: 3 pods do not fit "pods: 2", and 3 x 100m = 300m does
|
||||
# not fit 250m either. Raise both - the quota has to stay and still cap pods.
|
||||
k -n %{ns} patch quota %{ns}-quota -p '{"spec":{"hard":{"pods":"3","requests.cpu":"300m"}}}'
|
||||
|
||||
# Step 3: The PriorityClass
|
||||
k create priorityclass %{dep}-prio --value=50000
|
||||
|
||||
# Step 4: One pod per worker (required anti-affinity on hostname) that tolerates {W1}'s taint
|
||||
# Step 2: Deploy pods with podAntiAffinity, tolerations, PriorityClass, and fitted resources
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata: {name: %{dep}, namespace: %{ns}}
|
||||
metadata: {name: anti-workload, namespace: %{ns}}
|
||||
spec:
|
||||
replicas: 3
|
||||
selector: {matchLabels: {app: %{dep}}}
|
||||
selector: {matchLabels: {app: anti-workload}}
|
||||
template:
|
||||
metadata: {labels: {app: %{dep}}}
|
||||
metadata: {labels: {app: anti-workload}}
|
||||
spec:
|
||||
priorityClassName: %{dep}-prio
|
||||
priorityClassName: high-priority
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector: {matchLabels: {app: %{dep}}}
|
||||
- labelSelector: {matchLabels: {app: anti-workload}}
|
||||
topologyKey: kubernetes.io/hostname
|
||||
tolerations:
|
||||
- {key: %{key}, operator: Equal, value: "%{val}", effect: NoSchedule}
|
||||
- key: dedicated
|
||||
operator: Equal
|
||||
value: special
|
||||
effect: NoSchedule
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.27
|
||||
resources: {requests: {cpu: 100m, memory: 64Mi}}
|
||||
- name: app
|
||||
image: busybox:1.36
|
||||
command: [sleep, "3600"]
|
||||
resources: {requests: {cpu: 50m, memory: 32Mi}, limits: {cpu: 100m, memory: 64Mi}}
|
||||
EOF
|
||||
|
||||
# Step 5: Verify: 3 Running on 3 different nodes. The control-plane is tainted too, so with three
|
||||
# workers there is zero slack - a missed quota field or toleration shows up as a Pending pod, and
|
||||
# `k -n %{ns} describe rs` tells you which (FailedCreate: exceeded quota).
|
||||
k -n %{ns} get pod -l app=%{dep} -o wide
|
||||
k -n %{ns} describe quota %{ns}-quota # pods 3/3, requests.cpu 300m/300m
|
||||
# Step 3: Confirm each replica landed on a distinct node
|
||||
k -n %{ns} get pods -o wide -l app=anti-workload
|
||||
"""
|
||||
|
||||
S[51] = """# Step 1: Find WHICH revision used nginx:1.25 - the task asks for that one specifically, which
|
||||
S[108] = """# Step 1: Find WHICH revision used nginx:1.25 - the task asks for that one specifically, which
|
||||
# is not necessarily the previous one. --revision=N shows a single revision's pod template.
|
||||
k -n %{ns} rollout history deploy %{dep}
|
||||
for r in $(k -n %{ns} rollout history deploy %{dep} | awk '/^[0-9]/{print $1}'); do
|
||||
@ -863,10 +845,8 @@ k -n %{ns} get pod -l app=%{dep} -o jsonpath='{.items[*].spec.containers[0].imag
|
||||
k -n %{ns} get rs -o custom-columns=NAME:.metadata.name,IMAGE:.spec.template.spec.containers[0].image
|
||||
"""
|
||||
|
||||
S[52] = """# Step 1: Deploy Pod with a native sidecar (restartPolicy: Always inside initContainers),
|
||||
# a shared emptyDir the sidecar writes into and the main container reads from. Mount it at
|
||||
# /var/log/agent, NOT /var/log: an emptyDir over /var/log hides nginx's /var/log/nginx and
|
||||
# nginx crash-loops with "could not open error log file"
|
||||
S[109] = """# Step 1: Deploy Pod with a native sidecar (restartPolicy: Always inside initContainers),
|
||||
# a shared emptyDir the sidecar writes into and the main container reads from
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
@ -880,9 +860,9 @@ spec:
|
||||
- name: agent
|
||||
image: busybox:1.36
|
||||
restartPolicy: Always
|
||||
command: [sh, -c, "while true; do date >> /var/log/agent/agent.log; sleep 1; done"]
|
||||
command: [sh, -c, "while true; do date >> /var/log/agent.log; sleep 1; done"]
|
||||
volumeMounts:
|
||||
- {name: logs, mountPath: /var/log/agent}
|
||||
- {name: logs, mountPath: /var/log}
|
||||
containers:
|
||||
- name: app
|
||||
image: nginx:1.27
|
||||
@ -892,49 +872,32 @@ spec:
|
||||
livenessProbe:
|
||||
tcpSocket: {port: 80}
|
||||
volumeMounts:
|
||||
- {name: logs, mountPath: /var/log/agent}
|
||||
- {name: logs, mountPath: /var/log}
|
||||
EOF
|
||||
|
||||
# Step 2: Once Running, write the exact report files
|
||||
k -n %{ns} get pod %{pod} -o jsonpath='{.spec.initContainers[*].name} {.spec.containers[*].name}' \\
|
||||
| tr ' ' '\\n' | sort > {WS}/q52/containers.txt
|
||||
k -n %{ns} get pod %{pod} -o jsonpath='{.status.podIP}' > {WS}/q52/ip.txt
|
||||
| tr ' ' '\\n' | sort > {WS}/q109/containers.txt
|
||||
k -n %{ns} get pod %{pod} -o jsonpath='{.status.podIP}' > {WS}/q109/ip.txt
|
||||
"""
|
||||
|
||||
S[53] = r"""# Step 0: look before you script - which pods, which phases, which nodes
|
||||
k -n %{ns} get pods -o wide
|
||||
S[110] = r"""# Step 1: pods outside kube-system whose FIRST container has no readiness probe
|
||||
k get pods -A -o json | jq -r '.items[] | select(.metadata.namespace!="kube-system") | select(.spec.containers[0].readinessProbe == null) | "\(.metadata.namespace)/\(.metadata.name)"' | sort > {WS}/q110/no-readiness.txt
|
||||
|
||||
# Step 1: FIRST container without a readinessProbe, any phase (the Completed pod counts too).
|
||||
# jsonpath prints nothing for a missing field, so emit "<name> <probe-or-empty>" and keep the bare names.
|
||||
k -n %{ns} get pods -o jsonpath='{range .items[*]}{.metadata.name} {.spec.containers[0].readinessProbe}{"\n"}{end}' \
|
||||
| awk 'NF==1{print $1}' | sort > {WS}/q53/no-readiness.txt
|
||||
# Step 2: count of Running pods per node ("<node> <count>", node first)
|
||||
k get pods -A --field-selector=status.phase=Running -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' | sort | uniq -c | awk '{print $2, $1}' | sort > {WS}/q110/per-node.txt
|
||||
|
||||
# Step 2: Running pods per node, "<node> <count>". uniq -c prints "count node", so swap the columns.
|
||||
k -n %{ns} get pods --field-selector=status.phase=Running -o jsonpath='{range .items[*]}{.spec.nodeName}{"\n"}{end}' \
|
||||
| sort | uniq -c | awk '{print $2, $1}' | sort > {WS}/q53/per-node.txt
|
||||
|
||||
# Step 3: distinct images in kube-system
|
||||
k -n kube-system get pods -o jsonpath='{.items[*].spec.containers[*].image}' | tr ' ' '\n' | sort -u > {WS}/q53/images.txt
|
||||
|
||||
# Traps: cache-a has a readiness probe - on its SECOND container, so it belongs in file 1;
|
||||
# once-a is Completed, so it is in file 1 but not in file 2. `cat -A` shows stray spaces or \r.
|
||||
# Step 3: sorted unique container images used across kube-system
|
||||
k -n kube-system get pods -o jsonpath='{.items[*].spec.containers[*].image}' | tr ' ' '\n' | sort -u > {WS}/q110/images.txt
|
||||
"""
|
||||
|
||||
S[64] = """# Step 1: Switch context and find out which namespace it defaults to
|
||||
k config use-context %{ctx}
|
||||
k config view --minify -o jsonpath='{..namespace}{"\\n"}' # -> %{ns}
|
||||
S[121] = """# Step 1: Confirm current context namespace
|
||||
k config view --minify -o jsonpath='{.contexts[0].context.namespace}'
|
||||
|
||||
# Step 2: Everything WITHOUT -n, so it lands in the context's namespace
|
||||
k create deploy %{dep} --image=busybox:1.36 --replicas=2 -- sleep 3600
|
||||
k label deploy %{dep} owner=ac
|
||||
k expose deploy %{dep} --port=9000 --target-port=9000 # ClusterIP is the default type
|
||||
|
||||
# Step 3: Record the namespace exactly as kubectl reports it
|
||||
k config view --minify -o jsonpath='{..namespace}' > {WS}/q64/ns.txt
|
||||
|
||||
# Step 4: Verify it all landed there - and nothing in default
|
||||
k get deploy,svc %{dep}
|
||||
k -n default get deploy %{dep} # NotFound is the right answer
|
||||
# Step 2: Perform all required operations without specifying -n, relying on context namespace
|
||||
k create deploy %{dep} --image=nginx:1.27 --replicas=2
|
||||
k set env deploy/%{dep} APP_ENV=production
|
||||
k expose deploy %{dep} --port=80 --target-port=80
|
||||
"""
|
||||
|
||||
|
||||
@ -1127,36 +1090,31 @@ EOF
|
||||
k -n %{ns} describe netpol %{app}-egress
|
||||
"""
|
||||
|
||||
S[43] = """# Step 1: The headless Service must be named EXACTLY like the StatefulSet's spec.serviceName -
|
||||
# that name is what makes CoreDNS publish one record per pod: <pod>.<service>.<ns>.svc.cluster.local
|
||||
k -n %{ns} get sts %{sts} -o jsonpath='{.spec.serviceName}{"\\n"}' # -> %{sts}
|
||||
S[43] = """# Step 1: Create Headless Service (clusterIP: None)
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: %{sts}, namespace: %{ns}}
|
||||
metadata: {name: db-headless, namespace: %{ns}}
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector: {app: %{sts}}
|
||||
selector: {app: db}
|
||||
ports: [{port: 5432, targetPort: 5432}]
|
||||
---
|
||||
# Step 2: Create ExternalName Service pointing to external domain
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata: {name: legacy, namespace: %{ns}}
|
||||
metadata: {name: external-api, namespace: %{ns}}
|
||||
spec:
|
||||
type: ExternalName
|
||||
externalName: %{ext}
|
||||
externalName: api.partner.com
|
||||
EOF
|
||||
|
||||
# Step 2: Resolve pod 0 by its FULL name. busybox's nslookup applies no search domains, so the
|
||||
# short form %{sts}-0.%{sts} returns NXDOMAIN even though DNS is perfectly healthy.
|
||||
k -n %{ns} exec %{sts}-0 -- nslookup %{sts}-0.%{sts}.%{ns}.svc.cluster.local > {WS}/q43/pod0.txt
|
||||
cat {WS}/q43/pod0.txt # Address: <the pod's IP>
|
||||
k -n %{ns} get pod %{sts}-0 -o wide # same IP
|
||||
k -n %{ns} get svc legacy # EXTERNAL-IP column shows %{ext} - it is only a CNAME
|
||||
# Step 3: Verify DNS resolution for headless endpoints and external CNAME
|
||||
k -n %{ns} get svc
|
||||
"""
|
||||
|
||||
# Hard Networking Tasks
|
||||
S[54] = """# Step 1: Label the source namespace for ingress matching
|
||||
S[111] = """# Step 1: Label the source namespace for ingress matching
|
||||
k label ns %{from} team=%{from}
|
||||
|
||||
# Step 2: Apply default-deny (ingress + egress), allow-dns egress, and tiered service policies
|
||||
@ -1208,7 +1166,7 @@ spec:
|
||||
EOF
|
||||
"""
|
||||
|
||||
S[55] = """# Step 1: Generate self-signed TLS certificate and private key
|
||||
S[112] = """# Step 1: Generate self-signed TLS certificate and private key
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=%{host}" -keyout tls.key -out tls.crt
|
||||
|
||||
# Step 2: Create TLS secret in target namespace
|
||||
@ -1219,10 +1177,10 @@ k -n %{ns} create ingress web --class=nginx --rule="%{host}/*=web:80,tls=%{host}
|
||||
--annotation nginx.ingress.kubernetes.io/ssl-redirect=true
|
||||
|
||||
# Step 4: Extract SHA256 fingerprint to workspace file
|
||||
openssl x509 -in tls.crt -noout -fingerprint -sha256 | cut -d= -f2 > {WS}/q55/fp.txt
|
||||
openssl x509 -in tls.crt -noout -fingerprint -sha256 | cut -d= -f2 > {WS}/q112/fp.txt
|
||||
"""
|
||||
|
||||
S[56] = """# Step 1: Create GatewayClass, Gateway with multi-host listeners, and canary HTTPRoute
|
||||
S[113] = """# Step 1: Create GatewayClass, Gateway with multi-host listeners, and canary HTTPRoute
|
||||
cat <<EOF | k apply -f -
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: GatewayClass
|
||||
@ -1252,7 +1210,7 @@ spec:
|
||||
EOF
|
||||
"""
|
||||
|
||||
S[57] = """# Step 1: Remove stray blocking NetworkPolicy in kube-system
|
||||
S[114] = """# Step 1: Remove stray blocking NetworkPolicy in kube-system
|
||||
k -n kube-system get netpol
|
||||
k -n kube-system delete netpol maintenance-lockdown
|
||||
|
||||
@ -1265,9 +1223,7 @@ k -n kube-system edit cm coredns
|
||||
|
||||
# Step 4: Restart CoreDNS deployment and test DNS resolution
|
||||
k -n kube-system rollout restart deploy coredns
|
||||
# busybox's nslookup applies no search domains, so use FULL names (a short one fails even when DNS works)
|
||||
k run dns-check --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default.svc.cluster.local
|
||||
k run dns-ext --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.io
|
||||
k run dns-check --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default
|
||||
"""
|
||||
|
||||
|
||||
@ -1402,7 +1358,7 @@ k -n %{ns} exec reader -- cat /data/marker.txt
|
||||
"""
|
||||
|
||||
# Hard Storage Tasks
|
||||
S[58] = """# Step 1: Identify PV labels and corresponding node affinity
|
||||
S[115] = """# Step 1: Identify PV labels and corresponding node affinity
|
||||
k get pv -l media --show-labels
|
||||
k get node -l disk=nvme
|
||||
|
||||
@ -1430,7 +1386,7 @@ EOF
|
||||
k -n %{ns} get pod consumer -o wide
|
||||
"""
|
||||
|
||||
S[59] = """# Step 1: Create target PVC for migration
|
||||
S[116] = """# Step 1: Create target PVC for migration
|
||||
k -n %{ns} apply -f - <<EOF
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
@ -1463,13 +1419,8 @@ k -n %{ns} get pod legacy -o yaml > legacy.yaml
|
||||
# Edit legacy.yaml: replace claimName: old-data with claimName: new-data
|
||||
k -n %{ns} delete pod legacy --now && k apply -f legacy.yaml
|
||||
|
||||
# Step 5: Delete the old claim. The Job's finished pod still references old-data, and a pod that
|
||||
# references a claim - even a Completed one - holds it in pvc-protection: `delete pvc` just sits
|
||||
# there (describe pvc -> "Used By: migrate-xxxxx"). Delete that pod first; the Job itself stays,
|
||||
# still reporting succeeded: 1.
|
||||
k -n %{ns} delete pod -l job-name=migrate
|
||||
# Step 5: Clean up old PVC (PV remains Retained)
|
||||
k -n %{ns} delete pvc old-data
|
||||
k get pv | grep old-data # STATUS Released - Retain kept the data
|
||||
"""
|
||||
|
||||
|
||||
@ -1567,9 +1518,7 @@ k -n kube-system edit cm coredns
|
||||
k -n kube-system rollout restart deploy coredns
|
||||
|
||||
# Step 5: Verify DNS resolution from a test pod
|
||||
# busybox's nslookup applies no search domains, so use FULL names (a short one fails even when DNS works)
|
||||
k run test-dns --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default.svc.cluster.local
|
||||
k run test-ext --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.io
|
||||
k run test-dns --image=busybox:1.36 --rm -it --restart=Never -- nslookup kubernetes.default
|
||||
"""
|
||||
|
||||
S[38] = """# Step 1: Check control-plane pod status
|
||||
@ -1611,7 +1560,7 @@ systemctl status kubelet
|
||||
"""
|
||||
|
||||
# Hard Troubleshooting Tasks
|
||||
S[60] = """# Step 1: Check node status in cluster
|
||||
S[117] = """# Step 1: Check node status in cluster
|
||||
k get node {W2} # NotReady,SchedulingDisabled
|
||||
|
||||
# Step 2: Access the node
|
||||
@ -1633,7 +1582,7 @@ k get pod test-pod -o wide
|
||||
k delete pod test-pod --now
|
||||
"""
|
||||
|
||||
S[61] = """# Step 1: Check control plane pods in kube-system
|
||||
S[118] = """# Step 1: Check control plane pods in kube-system
|
||||
k -n kube-system get pods
|
||||
# scheduler CrashLoop, controller-manager ImagePullBackOff
|
||||
|
||||
@ -1655,16 +1604,13 @@ k get deploy probe
|
||||
k delete deploy probe
|
||||
"""
|
||||
|
||||
S[62] = """# Step 1: Diagnose namespace quota and deployment events
|
||||
S[119] = """# Step 1: Diagnose namespace quota and deployment events
|
||||
k -n %{ns} get all
|
||||
k -n %{ns} describe deploy %{dep}
|
||||
k -n %{ns} describe rs -l app=%{dep} # Quota exceeded: pods limit is 1
|
||||
|
||||
# Step 2: Fix the quota - with room for the rolling update, not just the end state. The default
|
||||
# strategy (maxSurge 25% -> 1, maxUnavailable 25% of 3 -> 0) needs a 4th pod mid-rollout, and with
|
||||
# maxUnavailable 0 the controller will not remove an old pod first either: at pods=3 the rollout
|
||||
# deadlocks at "0 out of 3 new replicas have been updated".
|
||||
k -n %{ns} patch quota tight -p '{"spec":{"hard":{"pods":"4"}}}'
|
||||
# Step 2: Fix quota to accommodate required replicas
|
||||
k -n %{ns} patch quota tight -p '{"spec":{"hard":{"pods":"3"}}}'
|
||||
|
||||
# Step 3: Fix deployment image tag
|
||||
k -n %{ns} set image deploy %{dep} web=nginx:1.27
|
||||
@ -1681,24 +1627,24 @@ k -n %{ns} get deploy %{dep}
|
||||
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{dep}
|
||||
"""
|
||||
|
||||
S[63] = r"""# Gather each fact and append it directly to the report file, one key=value line at a time
|
||||
S[120] = r"""# Gather each fact and append it directly to the report file, one key=value line at a time
|
||||
pc=$(k -n kube-system get pod -l component=kube-controller-manager -o jsonpath='{.items[0].spec.containers[0].command}' | grep -o 'cluster-cidr=[^,"]*' | cut -d= -f2)
|
||||
echo "pod-cidr=$pc" > {WS}/q63/facts.txt
|
||||
echo "pod-cidr=$pc" > {WS}/q120/facts.txt
|
||||
|
||||
sc=$(k -n kube-system get pod -l component=kube-apiserver -o jsonpath='{.items[0].spec.containers[0].command}' | grep -o 'service-cluster-ip-range=[^,"]*' | cut -d= -f2)
|
||||
echo "service-cidr=$sc" >> {WS}/q63/facts.txt
|
||||
echo "service-cidr=$sc" >> {WS}/q120/facts.txt
|
||||
|
||||
d=$(k -n kube-system get svc kube-dns -o jsonpath='{.spec.clusterIP}')
|
||||
echo "dns-ip=$d" >> {WS}/q63/facts.txt
|
||||
echo "dns-ip=$d" >> {WS}/q120/facts.txt
|
||||
|
||||
# Empty mode in the ConfigMap means kube-proxy defaults to iptables
|
||||
m=$(k -n kube-system get cm kube-proxy -o jsonpath='{.data.config\.conf}' | grep '^mode:' | awk '{print $2}' | tr -d '"')
|
||||
echo "proxy-mode=${m:-iptables}" >> {WS}/q63/facts.txt
|
||||
echo "proxy-mode=${m:-iptables}" >> {WS}/q120/facts.txt
|
||||
|
||||
echo "%{node}-pod-cidr=$(k get node %{node} -o jsonpath='{.spec.podCIDR}')" >> {WS}/q63/facts.txt
|
||||
echo "%{node}-pod-cidr=$(k get node %{node} -o jsonpath='{.spec.podCIDR}')" >> {WS}/q120/facts.txt
|
||||
"""
|
||||
|
||||
S[66] = """# Step 1: Confirm nothing works through kubectl at all
|
||||
S[123] = """# Step 1: Confirm nothing works through kubectl at all
|
||||
kubectl get nodes # connection refused / timeout -- the API server itself is down
|
||||
|
||||
# Step 2: Access the control-plane node directly (kubectl can't help you here)
|
||||
|
||||
@ -535,8 +535,7 @@ Namespace `%{ns}` runs Pods labelled `app=db` and `app=%{client}`.
|
||||
Create NetworkPolicy `db-allow` so `app=db` Pods accept ingress **only** from `app=%{client}`
|
||||
Pods in the same Namespace on TCP 5432, and nothing else. Do not restrict egress.
|
||||
|
||||
(kindnet enforces NetworkPolicy here, so a wrong policy really blocks traffic; you are graded
|
||||
on the object.)
|
||||
(kindnet doesn't enforce policies here, so you are graded on the object.)
|
||||
""",
|
||||
"setup": NS + r"""
|
||||
mkns %{ns}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""CKS track. Starter bank.
|
||||
|
||||
Caveat worth knowing before you rely on this: several CKS objectives need infrastructure the
|
||||
default kind cluster does not have - there is no Falco, AppArmor profile loader, gVisor runtime
|
||||
or image scanner installed. (NetworkPolicy IS enforced: recent kindnet embeds a policy engine.) Tasks here either
|
||||
default kind cluster does not have - kindnet does not enforce NetworkPolicy, and there is no
|
||||
Falco, AppArmor profile loader, gVisor runtime or image scanner installed. Tasks here either
|
||||
stick to what a stock kubeadm/kind cluster can really do (PSA, RBAC, admission policies, audit
|
||||
logging, seccomp, token hygiene) or are graded on the object rather than on enforcement, and say so.
|
||||
"""
|
||||
@ -43,8 +43,7 @@ A NetworkPolicy has exactly one `podSelector`, so the egress half (selecting `we
|
||||
ingress half (selecting `db`) cannot live in the same object. Once a default deny is in place
|
||||
both halves are required — forgetting one is the classic CKS mistake.
|
||||
|
||||
(This cluster's kindnet enforces NetworkPolicy, so a gap really drops traffic; you are graded
|
||||
on the objects.)
|
||||
(kindnet does not enforce policies; you are graded on the objects.)
|
||||
""",
|
||||
"setup": NS + r"""
|
||||
mkns %{ns}
|
||||
|
||||
@ -1,210 +1,276 @@
|
||||
# CKA weak spots
|
||||
|
||||
_Generated by exam from 10 attempt(s); regenerated on every `end`._
|
||||
_Generated by exam from 11 attempt(s); regenerated on every `end`._
|
||||
|
||||
## Domain scores (latest full exam, 2026-09-08T19:59, 14.3%)
|
||||
## Domain scores (latest full exam, 2026-09-20T17:32, 100.0%)
|
||||
|
||||
- **ARCH** 12/26 — Cluster Architecture, Installation & Configuration (25%)
|
||||
- **WORK** 1/16 — Workloads & Scheduling (15%)
|
||||
- **NET** 0/17 — Services & Networking (20%)
|
||||
- **STOR** 0/11 — Storage (10%)
|
||||
- **TSHOOT** 2/35 — Troubleshooting (30%)
|
||||
- **ARCH** 38/266 — Cluster Architecture, Installation & Configuration (25%)
|
||||
- **WORK** 36/187 — Workloads & Scheduling (15%)
|
||||
- **NET** 37/179 — Services & Networking (20%)
|
||||
- **STOR** 19/85 — Storage (10%)
|
||||
- **TSHOOT** 33/171 — Troubleshooting (30%)
|
||||
|
||||
## Drill next (`./exam drill weak`)
|
||||
|
||||
### Q02 · ARCH · etcd snapshot (1/2 failed, last 0/7 pts, last failed 2026-09-19T12:27)
|
||||
### Q02 · ARCH · etcd snapshot (1/2 failed, last 0/7 pts, 2026-09-19T12:27)
|
||||
- ✗ /opt/cka/etcd-snapshot.db exists on the control-plane node
|
||||
- ✗ snapshot is a real etcd backup (> 100 KB)
|
||||
|
||||
### Q06 · ARCH · Certificates: expiry check and user CSR (2/3 failed, last 6/6 pts, last failed 2026-09-12T22:17)
|
||||
- passed last time (6/6) - one more clean pass takes it off this list
|
||||
### Q06 · ARCH · Certificates: expiry check and user CSR (2/3 failed, last 6/6 pts, 2026-09-12T22:17)
|
||||
- ✗ CSR jane exists with signer kubernetes.io/kube-apiserver-client
|
||||
- ✗ CSR jane approved and certificate issued
|
||||
|
||||
### Q15 · STOR · PersistentVolume, claim and pod (3/4 failed, last 6/6 pts, last failed 2026-09-15T20:55)
|
||||
- passed last time (6/6) - one more clean pass takes it off this list
|
||||
### Q13 · NET · Ingress routing (2/4 failed, last 5/5 pts, 2026-09-14T16:49)
|
||||
- ✗ ingressClassName nginx and host shop.example.com
|
||||
- ✗ /api (Prefix) -> api:8080
|
||||
- ✗ / (Prefix) -> web:80
|
||||
|
||||
### Q16 · STOR · Default StorageClass and dynamic provisioning (4/4 failed, last 0/5 pts, last failed 2026-09-16T19:40)
|
||||
### Q15 · STOR · PersistentVolume, claim and pod (3/4 failed, last 6/6 pts, 2026-09-15T20:55)
|
||||
- ✗ PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual
|
||||
- ✗ PVC pvc-data Bound to pv-data
|
||||
- ✗ data-pod Running with claim mounted at /usr/share/data
|
||||
|
||||
### Q16 · STOR · Default StorageClass and dynamic provisioning (4/4 failed, last 0/5 pts, 2026-09-16T19:40)
|
||||
- ✗ StorageClass fast-local: local-path, WaitForFirstConsumer, Delete
|
||||
- ✗ fast-local is default and standard is not
|
||||
- ✗ fast-claim picked up fast-local and is Bound
|
||||
- ✗ fast-pod Running with claim at /data
|
||||
|
||||
### Q17 · TSHOOT · Node NotReady (3/4 failed, last 8/8 pts, last failed 2026-09-13T01:06)
|
||||
- passed last time (8/8) - one more clean pass takes it off this list
|
||||
### Q17 · TSHOOT · Node NotReady (3/4 failed, last 8/8 pts, 2026-09-13T01:06)
|
||||
- ✗ kubelet is active
|
||||
- ✗ kubelet is enabled (survives reboot)
|
||||
|
||||
### Q19 · TSHOOT · CrashLoopBackOff diagnosis (4/5 failed, last 6/6 pts, last failed 2026-09-14T16:49)
|
||||
- passed last time (6/6) - one more clean pass takes it off this list
|
||||
### Q19 · TSHOOT · CrashLoopBackOff diagnosis (4/5 failed, last 6/6 pts, 2026-09-14T16:49)
|
||||
- ✗ crasher.log captured and contains the DB_HOST failure
|
||||
|
||||
### Q28 · WORK · Init container prepares content (4/4 failed, last 0/4 pts, last failed 2026-09-16T19:40)
|
||||
### Q25 · WORK · Taints and tolerations (3/5 failed, last 4/4 pts, 2026-09-15T20:55)
|
||||
- ✗ cka-sim-worker carries team=finance:NoSchedule
|
||||
- ✗ deployment tolerates the taint
|
||||
- ✗ both pods Running on cka-sim-worker
|
||||
|
||||
### Q28 · WORK · Init container prepares content (4/4 failed, last 0/4 pts, 2026-09-16T19:40)
|
||||
- ✗ pod Running, init container named setup completed
|
||||
- ✗ emptyDir 'content' shared by both containers
|
||||
- ✗ nginx serves the init-written content
|
||||
|
||||
### Q29 · WORK · Immutable ConfigMap rotation (3/4 failed, last 4/4 pts, last failed 2026-09-15T20:55)
|
||||
- passed last time (4/4) - one more clean pass takes it off this list
|
||||
### Q29 · WORK · Immutable ConfigMap rotation (3/4 failed, last 4/4 pts, 2026-09-15T20:55)
|
||||
- ✗ deployment now references a different ConfigMap
|
||||
- ✗ all pods available and see mode=new
|
||||
|
||||
### Q30 · NET · Named ports (2/3 failed, last 4/4 pts, last failed 2026-09-14T16:49)
|
||||
- passed last time (4/4) - one more clean pass takes it off this list
|
||||
### Q30 · NET · Named ports (2/3 failed, last 4/4 pts, 2026-09-14T16:49)
|
||||
- ✗ container port 80 named svc-http
|
||||
- ✗ Service port 8080 -> targetPort svc-http (by name)
|
||||
- ✗ readiness probe uses the named port; endpoints ready
|
||||
|
||||
### Q32 · WORK · ResourceQuota and LimitRange (1/1 failed, last 0/4 pts, last failed 2026-09-19T12:27)
|
||||
### Q32 · WORK · ResourceQuota and LimitRange (1/1 failed, last 0/4 pts, 2026-09-19T12:27)
|
||||
- ✗ ResourceQuota limits pods/6, requests.cpu 1, limits.memory 2Gi
|
||||
- ✗ LimitRange defaults set
|
||||
- ✗ worker pods Running with defaulted requests
|
||||
|
||||
### Q33 · ARCH · Aggregated ClusterRole (2/3 failed, last 5/5 pts, last failed 2026-09-14T16:49)
|
||||
- passed last time (5/5) - one more clean pass takes it off this list
|
||||
### Q33 · ARCH · Aggregated ClusterRole (2/3 failed, last 5/5 pts, 2026-09-14T16:49)
|
||||
- ✗ ops-19 has an aggregationRule with the right label
|
||||
- ✗ aggregation populated rules from the two labelled roles
|
||||
- ✗ sam can list pods and services cluster-wide
|
||||
|
||||
### Q37 · TSHOOT · Cluster DNS is broken (4/5 failed, last 6/6 pts, last failed 2026-09-14T16:49)
|
||||
- passed last time (6/6) - one more clean pass takes it off this list
|
||||
### Q37 · TSHOOT · Cluster DNS is broken (4/5 failed, last 6/6 pts, 2026-09-14T16:49)
|
||||
- ✗ CoreDNS Corefile forwards to the node resolver again
|
||||
- ✗ in-cluster and external names resolve from a pod
|
||||
|
||||
### Q38 · TSHOOT · Deployments never create pods (2/3 failed, last 7/7 pts, last failed 2026-09-13T01:06)
|
||||
- passed last time (7/7) - one more clean pass takes it off this list
|
||||
### Q38 · TSHOOT · Deployments never create pods (2/3 failed, last 7/7 pts, 2026-09-13T01:06)
|
||||
- ✗ kube-controller-manager manifest repaired and pod Running
|
||||
- ✗ a new Deployment gets a ReplicaSet and running pods
|
||||
|
||||
### Q40 · ARCH · kubeadm join token (4/4 failed, last 0/5 pts, last failed 2026-09-16T19:40)
|
||||
### Q39 · TSHOOT · kubelet misconfiguration (3/5 failed, last 7/7 pts, 2026-09-14T16:49)
|
||||
- ✗ kubelet config points at a CA file that exists
|
||||
- ✗ node Ready
|
||||
- ✗ kubelet active and enabled
|
||||
|
||||
### Q40 · ARCH · kubeadm join token (4/4 failed, last 0/5 pts, 2026-09-16T19:40)
|
||||
- ✗ join.sh contains a kubeadm join with token and CA hash
|
||||
- ✗ the token in the file exists in the cluster with TTL ~24h
|
||||
|
||||
### Q42 · NET · Egress-only NetworkPolicy (2/3 failed, last 4/4 pts, last failed 2026-09-14T16:49)
|
||||
- passed last time (4/4) - one more clean pass takes it off this list
|
||||
### Q42 · NET · Egress-only NetworkPolicy (2/3 failed, last 4/4 pts, 2026-09-14T16:49)
|
||||
- ✗ applies to app=client-64, Egress only
|
||||
- ✗ DNS rule: kube-system namespace + k8s-app=kube-dns, ports 53 UDP and TCP
|
||||
- ✗ TCP 443 to 172.16.0.0/16 only
|
||||
|
||||
### Q45 · ARCH · etcd restore (1/1 failed, last 2/10 pts, last failed 2026-09-19T12:27)
|
||||
### Q102 · ARCH · etcd restore (1/1 failed, last 2/10 pts, 2026-09-19T12:27)
|
||||
- ✗ ConfigMap restore-proof-67 exists again with its original data
|
||||
- ✗ etcd uses a restored data directory (manifest changed)
|
||||
|
||||
### Q49 · ARCH · CRD with validation and defaults (4/4 failed, last 0/7 pts, last failed 2026-09-19T12:27)
|
||||
### Q106 · ARCH · CRD with validation and defaults (4/4 failed, last 0/7 pts, 2026-09-19T12:27)
|
||||
- ✗ CRD exists, Namespaced, replicas has min/max and is required
|
||||
- ✗ good created with environment prod
|
||||
- ✗ replicas 0 is rejected and the message was saved
|
||||
- ✗ defaulting works: defaulted.spec.environment == dev
|
||||
|
||||
### Q52 · WORK · Native sidecar with probes and an exact report (1/1 failed, last 2/8 pts, last failed 2026-09-19T12:27)
|
||||
### Q109 · WORK · Native sidecar with probes and an exact report (1/1 failed, last 2/8 pts, 2026-09-19T12:27)
|
||||
- ✗ native sidecar: initContainer agent with restartPolicy Always
|
||||
- ✗ probes on app as specified
|
||||
- ✗ Running, shared log written by agent, grace period 5
|
||||
|
||||
### Q55 · NET · TLS Ingress with a generated certificate (4/4 failed, last 0/7 pts, last failed 2026-09-19T12:27)
|
||||
### Q112 · NET · TLS Ingress with a generated certificate (4/4 failed, last 0/7 pts, 2026-09-19T12:27)
|
||||
- ✗ Secret secure.lab.local-tls is kubernetes.io/tls with CN secure.lab.local
|
||||
- ✗ Ingress web: class nginx, host, TLS secret, ssl-redirect annotation
|
||||
- ✗ fp.txt matches the secret's certificate fingerprint
|
||||
|
||||
### Q58 · STOR · Bind the right local volume (3/3 failed, last 1/6 pts, last failed 2026-09-15T20:55)
|
||||
### Q115 · STOR · Bind the right local volume (3/3 failed, last 1/6 pts, 2026-09-15T20:55)
|
||||
- ✗ PVC uses a selector and is Bound to lv-nvme
|
||||
- ✗ consumer Running on the nvme node with /data mounted
|
||||
|
||||
### Q63 · TSHOOT · Cluster facts, exactly (3/3 failed, last 0/5 pts, last failed 2026-09-15T20:55)
|
||||
### Q120 · TSHOOT · Cluster facts, exactly (3/3 failed, last 0/5 pts, 2026-09-15T20:55)
|
||||
- ✗ pod-cidr and service-cidr
|
||||
- ✗ dns-ip and proxy-mode
|
||||
- ✗ cka-sim-worker3 pod CIDR
|
||||
|
||||
### Q64 · WORK · The context's namespace (3/3 failed, last 1/6 pts, last failed 2026-09-15T20:55)
|
||||
### Q121 · WORK · The context's namespace (3/3 failed, last 1/6 pts, 2026-09-15T20:55)
|
||||
- ✗ Deployment ledger-21 in ac-echo (the context's namespace), 2 available, owner=ac
|
||||
- ✗ Service ledger-21 port 9000 in the same namespace
|
||||
- ✗ ns.txt has the namespace
|
||||
|
||||
### Q202 · ARCH · Helm chart with CRDs, then a custom resource (1/1 failed, last 3/8 pts, 2026-09-19T12:27)
|
||||
- ✗ watchAllNamespaces=true reached the pod env
|
||||
- ✗ Ledger main-ledger has currency USD and retentionDays 30
|
||||
|
||||
### Q204 · WORK · Which Pods get evicted first (1/1 failed, last 0/8 pts, 2026-09-19T12:27)
|
||||
- ✗ evicted-first.txt lists exactly the BestEffort pods
|
||||
- ✗ api-qos.txt says Burstable
|
||||
- ✗ guaranteed.txt names orion-ledger
|
||||
|
||||
### Q217 · TSHOOT · Inspect a container with crictl (1/1 failed, last 0/10 pts, 2026-09-19T12:27)
|
||||
- ✗ Pod tiger-probe Running with both labels
|
||||
- ✗ container.txt holds a real container ID for that Pod
|
||||
- ✗ container.txt records the runtimeType
|
||||
- ✗ container.log holds the httpd startup output
|
||||
|
||||
### Q313 · NET · Two hosts, TLS, and a default backend (1/1 failed, last 0/11 pts, 2026-09-19T12:27)
|
||||
- ✗ Secret classic-tls is a TLS secret with CN classic.r700.io
|
||||
- ✗ both host rules route to the right Services on /
|
||||
- ✗ TLS covers both hosts with classic-tls
|
||||
- ✗ defaultBackend points at web-classic:80, class is nginx
|
||||
|
||||
## Attempt matrix
|
||||
|
||||
| Q | Domain | Title | D1 | E2 | D3 | D4 | D5 | D6 | D7 | D8 | D9 | D10 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | ARCH | RBAC for a deployment bot | 2/6 | · | · | ✓ | · | · | · | · | · | · |
|
||||
| 2 | ARCH | etcd snapshot | · | ✓ | · | · | · | · | · | · | · | 0/7 |
|
||||
| 3 | ARCH | Kustomize overlay | · | 0/5 | · | · | · | · | ✓ | · | · | · |
|
||||
| 4 | ARCH | CustomResourceDefinition | · | ✓ | · | · | · | · | · | · | · | ✓ |
|
||||
| 5 | ARCH | Static pod | 0/5 | · | · | · | · | · | ✓ | · | · | · |
|
||||
| 6 | ARCH | Certificates: expiry check and user CSR | 0/6 | · | · | 3/6 | · | · | ✓ | · | · | · |
|
||||
| 7 | WORK | Rolling update & scaling | · | · | · | · | · | · | · | · | · | · |
|
||||
| 8 | WORK | Multi-container pod with shared volume | · | 0/4 | · | · | · | · | ✓ | · | · | ✓ |
|
||||
| 9 | WORK | Scheduling with node affinity and resources | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 10 | WORK | ConfigMap and Secret consumption | · | · | · | · | · | · | · | · | · | · |
|
||||
| 11 | NET | NetworkPolicy | · | · | · | · | · | · | · | · | · | · |
|
||||
| 12 | NET | NodePort Service | · | 0/4 | · | · | · | · | ✓ | · | · | · |
|
||||
| 13 | NET | Ingress routing | · | 0/5 | · | · | · | · | 0/5 | ✓ | · | ✓ |
|
||||
| 14 | NET | Cluster DNS | · | · | · | · | · | · | · | · | · | · |
|
||||
| 15 | STOR | PersistentVolume, claim and pod | · | 0/6 | · | · | · | · | 0/6 | 0/6 | ✓ | · |
|
||||
| 16 | STOR | Default StorageClass and dynamic provisioning | · | 0/5 | · | · | · | · | 0/5 | 0/5 | 0/5 | · |
|
||||
| 17 | TSHOOT | Node NotReady | · | 0/8 | · | · | 4/8 | 4/8 | ✓ | · | · | · |
|
||||
| 18 | TSHOOT | Broken deployment and service | · | 0/7 | · | · | · | · | ✓ | · | · | · |
|
||||
| 19 | TSHOOT | CrashLoopBackOff diagnosis | · | 1/6 | · | · | 1/6 | 1/6 | 4/6 | ✓ | · | · |
|
||||
| 20 | TSHOOT | Control plane component failure | · | 0/8 | · | · | · | · | ✓ | · | · | · |
|
||||
| 21 | ARCH | Helm release install and upgrade | · | · | · | ✓ | · | · | · | · | · | · |
|
||||
| 22 | NET | Gateway API HTTPRoute | · | · | · | · | · | · | · | · | · | · |
|
||||
| 23 | WORK | metrics-server repair and HPA | · | · | · | · | · | · | · | · | · | · |
|
||||
| 24 | WORK | PriorityClass | · | · | · | · | · | · | · | · | · | · |
|
||||
| 25 | WORK | Taints and tolerations | · | 0/4 | · | · | · | · | 0/4 | 0/4 | ✓ | ✓ |
|
||||
| 26 | WORK | DaemonSet on every node | · | · | · | · | · | · | · | · | · | · |
|
||||
| 27 | WORK | CronJob with a manual run | · | · | · | · | · | · | · | · | · | · |
|
||||
| 28 | WORK | Init container prepares content | · | 0/4 | · | · | · | · | 0/4 | 0/4 | 0/4 | · |
|
||||
| 29 | WORK | Immutable ConfigMap rotation | · | 1/4 | · | · | · | · | 1/4 | 1/4 | ✓ | · |
|
||||
| 30 | NET | Named ports | · | 0/4 | · | · | · | · | 0/4 | ✓ | · | · |
|
||||
| 31 | WORK | Parallel Job | · | · | · | · | · | · | · | · | · | · |
|
||||
| 32 | WORK | ResourceQuota and LimitRange | · | · | · | · | · | · | · | · | · | 0/4 |
|
||||
| 33 | ARCH | Aggregated ClusterRole | · | 1/5 | · | · | · | · | 1/5 | ✓ | · | · |
|
||||
| 34 | ARCH | Discover a CRD field with kubectl explain | 0/4 | · | · | ✓ | · | · | · | · | · | ✓ |
|
||||
| 35 | STOR | Re-bind a Released PersistentVolume | · | · | · | · | · | · | · | · | · | · |
|
||||
| 36 | STOR | Data survives the pod | · | · | · | · | · | · | · | · | · | · |
|
||||
| 37 | TSHOOT | Cluster DNS is broken | · | 1/6 | · | · | 1/6 | 1/6 | 1/6 | ✓ | · | · |
|
||||
| 38 | TSHOOT | Deployments never create pods | · | · | · | · | 0/7 | 0/7 | ✓ | · | · | · |
|
||||
| 39 | TSHOOT | kubelet misconfiguration | · | · | · | · | 4/7 | 4/7 | 0/7 | ✓ | · | ✓ |
|
||||
| 40 | ARCH | kubeadm join token | · | 0/5 | · | · | · | · | 0/5 | 0/5 | 0/5 | · |
|
||||
| 41 | ARCH | Component versions | 0/4 | · | · | ✓ | · | · | · | · | · | · |
|
||||
| 42 | NET | Egress-only NetworkPolicy | · | 0/4 | · | · | · | · | 0/4 | ✓ | · | · |
|
||||
| 43 | NET | Headless and ExternalName services | · | · | · | · | · | · | · | · | · | · |
|
||||
| 44 | ARCH | Exact RBAC with an existing over-grant | · | · | · | · | · | · | · | · | · | · |
|
||||
| 45 | ARCH | etcd restore | · | · | · | · | · | · | · | · | · | 2/10 |
|
||||
| 46 | ARCH | Static pod on a relocated manifest path | · | · | · | · | · | · | · | · | · | · |
|
||||
| 47 | ARCH | User onboarding to a working kubeconfig | · | · | · | · | · | · | · | · | · | · |
|
||||
| 48 | ARCH | Kustomize with generators and patches | · | · | · | · | · | · | · | · | · | · |
|
||||
| 49 | ARCH | CRD with validation and defaults | · | · | 0/7 | · | · | · | 0/7 | 0/7 | · | 0/7 |
|
||||
| 50 | WORK | Spread, tolerate, prioritise — under a quota | · | · | · | · | · | · | · | · | · | · |
|
||||
| 51 | WORK | Rollback to a specific revision, then pause | · | · | · | · | · | · | · | · | · | · |
|
||||
| 52 | WORK | Native sidecar with probes and an exact report | · | · | · | · | · | · | · | · | · | 2/8 |
|
||||
| 53 | WORK | Namespace audit to exact files | · | · | · | · | · | · | · | · | · | · |
|
||||
| 54 | NET | Zero-trust namespace | · | · | · | · | · | · | · | · | · | · |
|
||||
| 55 | NET | TLS Ingress with a generated certificate | · | · | 0/7 | · | · | · | 0/7 | 0/7 | · | 0/7 |
|
||||
| 56 | NET | Gateway API canary with header match | · | · | · | · | · | · | · | · | · | · |
|
||||
| 57 | NET | DNS down: three faults | · | · | · | · | · | · | · | · | · | · |
|
||||
| 58 | STOR | Bind the right local volume | · | · | 1/6 | · | · | · | 1/6 | 1/6 | · | · |
|
||||
| 59 | STOR | Migrate data between claims | · | · | · | · | · | · | · | · | · | · |
|
||||
| 60 | TSHOOT | Worker node: three things wrong | · | · | · | · | · | · | · | · | · | · |
|
||||
| 61 | TSHOOT | Two control-plane components down | · | · | · | · | · | · | · | · | · | · |
|
||||
| 62 | TSHOOT | Application down for four reasons | · | · | · | · | · | · | · | · | · | · |
|
||||
| 63 | TSHOOT | Cluster facts, exactly | · | · | 0/5 | · | · | · | 0/5 | 0/5 | · | · |
|
||||
| 64 | WORK | The context's namespace | · | · | 1/6 | · | · | · | 1/6 | 1/6 | · | · |
|
||||
| 65 | ARCH | Renew control-plane certificates | · | · | · | · | · | · | · | · | · | · |
|
||||
| 66 | TSHOOT | Nothing works: kube-apiserver itself is down | · | · | · | · | · | · | · | · | · | · |
|
||||
| 67 | ARCH | Prepare a node join without shell access to the control plane | · | · | · | · | · | · | · | · | · | · |
|
||||
| 68 | STOR | Provision geo-fence persistent storage | · | · | · | · | · | · | · | · | · | · |
|
||||
| 69 | ARCH | Render a chart, then install it | · | · | · | · | · | · | · | · | · | · |
|
||||
| 70 | TSHOOT | Fix the driver onboarding app | · | · | · | · | · | · | · | · | · | · |
|
||||
| 71 | NET | Expose and verify the dispatch API | · | · | · | · | · | · | · | · | · | · |
|
||||
| 72 | STOR | Migrate ride history to a Retain volume | · | · | · | · | · | · | · | · | · | · |
|
||||
| 73 | TSHOOT | Fix ETA service connectivity | · | · | · | · | · | · | · | · | · | · |
|
||||
| 74 | NET | Fleet dashboard canary with Gateway API | · | · | · | · | · | · | · | · | · | · |
|
||||
| 75 | ARCH | Cluster-wide read access for the auth team | · | · | · | · | · | · | · | · | · | · |
|
||||
| 76 | WORK | Add an init container to the trip logger | · | · | · | · | · | · | · | · | · | · |
|
||||
| 77 | NET | Audit the CNI, then lock a namespace down | · | · | · | · | · | · | · | · | · | · |
|
||||
| 78 | WORK | Priority for the rider support portal | · | · | · | · | · | · | · | · | · | · |
|
||||
| 79 | WORK | Surge pricing configuration | · | · | · | · | · | · | · | · | · | · |
|
||||
| 80 | ARCH | Plan the control-plane upgrade | · | · | · | · | · | · | · | · | · | · |
|
||||
| 81 | TSHOOT | Fix the fare calculation pods | · | · | · | · | · | · | · | · | · | · |
|
||||
| 82 | WORK | Autoscale the route planner on memory | · | · | · | · | · | · | · | · | · | · |
|
||||
| 83 | NET | NetworkPolicy for the safety monitor | · | · | · | · | · | · | · | · | · | · |
|
||||
| 84 | ARCH | Enforce TLS 1.3 on the booking portal | · | · | · | · | · | · | · | · | · | · |
|
||||
| 85 | TSHOOT | Investigate vehicle tracker health | · | · | · | · | · | · | · | · | · | · |
|
||||
| 86 | ARCH | How the control plane is run | · | · | · | · | · | · | · | · | · | · |
|
||||
| 87 | TSHOOT | Cluster facts to an exact file | · | · | · | · | · | · | · | · | · | · |
|
||||
| 88 | NET | Ready only when its dependency answers | · | · | · | · | · | · | · | · | · | · |
|
||||
| 89 | WORK | Sorting done by kubectl | · | · | · | · | · | · | · | · | · | · |
|
||||
| 90 | TSHOOT | Event forensics around kube-proxy | · | · | · | · | · | · | · | · | · | · |
|
||||
| 91 | ARCH | API resources and a Role census | · | · | · | · | · | · | · | · | · | · |
|
||||
| 92 | TSHOOT | Kubelet unit points at nothing | · | · | · | · | · | · | · | · | · | · |
|
||||
| 93 | WORK | Secrets as a read-only volume and renamed env vars | · | · | · | · | · | · | · | · | · | · |
|
||||
| 94 | ARCH | Static Pod on the control plane behind a Service | · | · | · | · | · | · | · | · | · | · |
|
||||
| 95 | ARCH | The kubelet's two certificates | · | · | · | · | · | · | · | · | · | · |
|
||||
| 96 | WORK | A Pod for the control plane only | · | · | · | · | · | · | · | · | · | · |
|
||||
| 97 | TSHOOT | Stop the scheduler, schedule by hand | · | · | · | · | · | · | · | · | · | · |
|
||||
| 98 | ARCH | Helm release housekeeping | · | · | · | · | · | · | · | · | · | · |
|
||||
| 99 | STOR | Per-replica storage for a StatefulSet | · | · | · | · | · | · | · | · | · | · |
|
||||
| 100 | NET | Namespace AND pod selector, enforced | · | · | · | · | · | · | · | · | · | · |
|
||||
| 101 | NET | Resolves everything except the cluster | · | · | · | · | · | · | · | · | · | · |
|
||||
| 102 | STOR | A claim that can never bind | · | · | · | · | · | · | · | · | · | · |
|
||||
| Q | Domain | Title | D1 | E2 | D3 | D4 | D5 | D6 | D7 | D8 | D9 | D10 | E11 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | ARCH | RBAC for a deployment bot | 2/6 | · | · | ✓ | · | · | · | · | · | · | · |
|
||||
| 2 | ARCH | etcd snapshot | · | ✓ | · | · | · | · | · | · | · | 0/7 | · |
|
||||
| 3 | ARCH | Kustomize overlay | · | 0/5 | · | · | · | · | ✓ | · | · | · | · |
|
||||
| 4 | ARCH | CustomResourceDefinition | · | ✓ | · | · | · | · | · | · | · | ✓ | · |
|
||||
| 5 | ARCH | Static pod | 0/5 | · | · | · | · | · | ✓ | · | · | · | · |
|
||||
| 6 | ARCH | Certificates: expiry check and user CSR | 0/6 | · | · | 3/6 | · | · | ✓ | · | · | · | · |
|
||||
| 7 | WORK | Rolling update & scaling | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 8 | WORK | Multi-container pod with shared volume | · | 0/4 | · | · | · | · | ✓ | · | · | ✓ | · |
|
||||
| 9 | WORK | Scheduling with node affinity and resources | · | · | · | · | · | · | · | · | · | ✓ | · |
|
||||
| 10 | WORK | ConfigMap and Secret consumption | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 11 | NET | NetworkPolicy | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 12 | NET | NodePort Service | · | 0/4 | · | · | · | · | ✓ | · | · | · | · |
|
||||
| 13 | NET | Ingress routing | · | 0/5 | · | · | · | · | 0/5 | ✓ | · | ✓ | · |
|
||||
| 14 | NET | Cluster DNS | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 15 | STOR | PersistentVolume, claim and pod | · | 0/6 | · | · | · | · | 0/6 | 0/6 | ✓ | · | · |
|
||||
| 16 | STOR | Default StorageClass and dynamic provisioning | · | 0/5 | · | · | · | · | 0/5 | 0/5 | 0/5 | · | · |
|
||||
| 17 | TSHOOT | Node NotReady | · | 0/8 | · | · | 4/8 | 4/8 | ✓ | · | · | · | · |
|
||||
| 18 | TSHOOT | Broken deployment and service | · | 0/7 | · | · | · | · | ✓ | · | · | · | · |
|
||||
| 19 | TSHOOT | CrashLoopBackOff diagnosis | · | 1/6 | · | · | 1/6 | 1/6 | 4/6 | ✓ | · | · | · |
|
||||
| 20 | TSHOOT | Control plane component failure | · | 0/8 | · | · | · | · | ✓ | · | · | · | · |
|
||||
| 21 | ARCH | Helm release install and upgrade | · | · | · | ✓ | · | · | · | · | · | · | · |
|
||||
| 22 | NET | Gateway API HTTPRoute | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 23 | WORK | metrics-server repair and HPA | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 24 | WORK | PriorityClass | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 25 | WORK | Taints and tolerations | · | 0/4 | · | · | · | · | 0/4 | 0/4 | ✓ | ✓ | · |
|
||||
| 26 | WORK | DaemonSet on every node | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 27 | WORK | CronJob with a manual run | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 28 | WORK | Init container prepares content | · | 0/4 | · | · | · | · | 0/4 | 0/4 | 0/4 | · | · |
|
||||
| 29 | WORK | Immutable ConfigMap rotation | · | 1/4 | · | · | · | · | 1/4 | 1/4 | ✓ | · | · |
|
||||
| 30 | NET | Named ports | · | 0/4 | · | · | · | · | 0/4 | ✓ | · | · | · |
|
||||
| 31 | WORK | Parallel Job | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 32 | WORK | ResourceQuota and LimitRange | · | · | · | · | · | · | · | · | · | 0/4 | · |
|
||||
| 33 | ARCH | Aggregated ClusterRole | · | 1/5 | · | · | · | · | 1/5 | ✓ | · | · | · |
|
||||
| 34 | ARCH | Discover a CRD field with kubectl explain | 0/4 | · | · | ✓ | · | · | · | · | · | ✓ | · |
|
||||
| 35 | STOR | Re-bind a Released PersistentVolume | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 36 | STOR | Data survives the pod | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 37 | TSHOOT | Cluster DNS is broken | · | 1/6 | · | · | 1/6 | 1/6 | 1/6 | ✓ | · | · | · |
|
||||
| 38 | TSHOOT | Deployments never create pods | · | · | · | · | 0/7 | 0/7 | ✓ | · | · | · | · |
|
||||
| 39 | TSHOOT | kubelet misconfiguration | · | · | · | · | 4/7 | 4/7 | 0/7 | ✓ | · | ✓ | · |
|
||||
| 40 | ARCH | kubeadm join token | · | 0/5 | · | · | · | · | 0/5 | 0/5 | 0/5 | · | · |
|
||||
| 41 | ARCH | Component versions | 0/4 | · | · | ✓ | · | · | · | · | · | · | · |
|
||||
| 42 | NET | Egress-only NetworkPolicy | · | 0/4 | · | · | · | · | 0/4 | ✓ | · | · | · |
|
||||
| 43 | NET | Headless and ExternalName services | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 101 | ARCH | Exact RBAC with an existing over-grant | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 102 | ARCH | etcd restore | · | · | · | · | · | · | · | · | · | 2/10 | · |
|
||||
| 103 | ARCH | Static pod on a relocated manifest path | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 104 | ARCH | User onboarding to a working kubeconfig | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 105 | ARCH | Kustomize with generators and patches | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 106 | ARCH | CRD with validation and defaults | · | · | 0/7 | · | · | · | 0/7 | 0/7 | · | 0/7 | · |
|
||||
| 107 | WORK | Spread, tolerate, prioritise — under a quota | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 108 | WORK | Rollback to a specific revision, then pause | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 109 | WORK | Native sidecar with probes and an exact report | · | · | · | · | · | · | · | · | · | 2/8 | · |
|
||||
| 110 | WORK | Cluster audit to exact files | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 111 | NET | Zero-trust namespace | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 112 | NET | TLS Ingress with a generated certificate | · | · | 0/7 | · | · | · | 0/7 | 0/7 | · | 0/7 | · |
|
||||
| 113 | NET | Gateway API canary with header match | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 114 | NET | DNS down: three faults | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 115 | STOR | Bind the right local volume | · | · | 1/6 | · | · | · | 1/6 | 1/6 | · | · | · |
|
||||
| 116 | STOR | Migrate data between claims | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 117 | TSHOOT | Worker node: three things wrong | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 118 | TSHOOT | Two control-plane components down | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 119 | TSHOOT | Application down for four reasons | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 120 | TSHOOT | Cluster facts, exactly | · | · | 0/5 | · | · | · | 0/5 | 0/5 | · | · | · |
|
||||
| 121 | WORK | The context's namespace | · | · | 1/6 | · | · | · | 1/6 | 1/6 | · | · | · |
|
||||
| 122 | ARCH | Renew control-plane certificates | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 123 | TSHOOT | Nothing works: kube-apiserver itself is down | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 124 | ARCH | Prepare a node join without shell access to the control plane | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 201 | ARCH | kubeconfig forensics | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 202 | ARCH | Helm chart with CRDs, then a custom resource | · | · | · | · | · | · | · | · | · | 3/8 | · |
|
||||
| 203 | WORK | Find the controller, then scale it | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 204 | WORK | Which Pods get evicted first | · | · | · | · | · | · | · | · | · | 0/8 | · |
|
||||
| 205 | ARCH | Kustomize: drop a ConfigMap, add an HPA, override in prod | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 206 | STOR | PV, PVC and a Deployment that mounts it | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 207 | TSHOOT | Resource usage scripts | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 208 | ARCH | Drain a node for maintenance | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 209 | ARCH | Query the API from inside a Pod | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 210 | ARCH | ServiceAccount limited to two resources | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 211 | WORK | DaemonSet on every node | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 212 | WORK | One Pod per node, on purpose | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 213 | NET | Convert an Ingress to Gateway API, then split on a header | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 214 | ARCH | Certificate expiry, two ways | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 215 | NET | Egress policy with paired destinations | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 216 | NET | CoreDNS: back up, extend, verify | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 217 | TSHOOT | Inspect a container with crictl | · | · | · | · | · | · | · | · | · | 0/10 | · |
|
||||
| 301 | ARCH | etcd configuration facts | · | · | · | · | · | · | · | · | · | ✓ | · |
|
||||
| 302 | NET | Service rules on the node | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 303 | NET | A second Service CIDR | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 304 | ARCH | Bound tokens versus legacy tokens | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 305 | WORK | Schedule without the scheduler | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 306 | STOR | Grow a volume in place | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 307 | TSHOOT | Node accepts nothing new | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 308 | ARCH | API resource reference card | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 309 | WORK | A PodDisruptionBudget that blocks maintenance | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 310 | NET | Route to something outside the cluster | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 311 | ARCH | Image inventory across the cluster | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 312 | WORK | A Job that fails, then one that doesn't | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 313 | NET | Two hosts, TLS, and a default backend | · | · | · | · | · | · | · | · | · | 0/11 | · |
|
||||
| 314 | TSHOOT | A static pod that never starts | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 315 | STOR | Keep the data when the claim goes | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 316 | ARCH | Plan a control-plane upgrade | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 317 | TSHOOT | Certificates stop being issued | · | · | · | · | · | · | · | · | · | · | · |
|
||||
| 401 | STOR | Provision geo-fence persistent storage | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 402 | ARCH | Render a chart, then install it | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 403 | TSHOOT | Fix the driver onboarding app | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 404 | NET | Expose and verify the dispatch API | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 405 | STOR | Migrate ride history to a Retain volume | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 406 | TSHOOT | Fix ETA service connectivity | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 407 | NET | Fleet dashboard canary with Gateway API | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 408 | ARCH | Cluster-wide read access for the auth team | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 409 | WORK | Add an init container to the trip logger | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 410 | NET | Audit the CNI, then lock a namespace down | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 411 | WORK | Priority for the rider support portal | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 412 | WORK | Surge pricing configuration | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 413 | ARCH | Plan the control-plane upgrade | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 414 | TSHOOT | Fix the fare calculation pods | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 415 | WORK | Autoscale the route planner on memory | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 416 | NET | NetworkPolicy for the safety monitor | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 417 | ARCH | Enforce TLS 1.3 on the booking portal | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
| 418 | TSHOOT | Investigate vehicle tracker health | · | · | · | · | · | · | · | · | · | · | ✓ |
|
||||
|
||||
_E = full exam, D = drill, · = not in that session, n/m = partial credit._
|
||||
|
||||
@ -186,7 +186,8 @@
|
||||
<button class="opt" id="optNext"><b>Drill next domain</b><span id="nextDesc"></span></button>
|
||||
<button class="opt" id="optAll"><b>Drill the whole bank</b><span id="allDesc"></span></button>
|
||||
<button class="opt" id="optDrill"><b>Drill: weak / pick</b><span>Tasks you're weak on, or choose your own. Feedback per task while you work.</span></button>
|
||||
<span id="simOpts" style="display:contents"></span>
|
||||
<button class="opt" id="optSimA"><b>Simulator Set A</b><span id="simADesc"></span></button>
|
||||
<button class="opt" id="optSimB"><b>Simulator Set B</b><span id="simBDesc"></span></button>
|
||||
<button class="opt" id="optExam" style="grid-column:1/3"><b>Full exam</b><span id="examDesc"></span></button>
|
||||
</div>
|
||||
<div id="gate" class="hidden" style="color:var(--warn);margin-top:10px;font-size:13px"></div>
|
||||
@ -347,11 +348,11 @@
|
||||
const eligible = S.bank.filter(q => hard || q.difficulty !== 'hard'), seenN = eligible.filter(q => (S.seen || []).includes(q.id)).length;
|
||||
$('#allDesc').textContent = `${seenN}/${eligible.length} tasks attempted. 5 unseen tasks per session, round-robin across domains${hard ? ', hard first' : ''}; then whatever you failed.`;
|
||||
$('#nextDesc').textContent = `${S.track.domains[nxt]} — 5 tasks, ${hard ? 5 : 6} min each. Drills rotate through all domains before the exam.`;
|
||||
// one button per fixed set the track defines - never hard-code the letters: newer sets used to
|
||||
// be unreachable from here
|
||||
$('#simOpts').innerHTML = Object.entries(S.sets || {}).map(([k, info]) =>
|
||||
`<button class="opt" data-sim="${k}"><b>${esc(info.name)}</b><span>${info.count} fixed tasks, ${S.track.minutes} min, exam rules, no feedback until you submit.</span></button>`).join('');
|
||||
$('#simOpts').querySelectorAll('[data-sim]').forEach(el => el.onclick = () => startSim(el.dataset.sim));
|
||||
for (const s of ['a', 'b']) {
|
||||
const info = (S.sets || {})[s];
|
||||
const el = $('#sim' + s.toUpperCase() + 'Desc');
|
||||
if (el) el.textContent = info ? `${info.count} fixed tasks, ${S.track.minutes} min, exam rules. Hardest set in the bank — closest to a real sitting.` : '';
|
||||
}
|
||||
$('#gate').classList.toggle('hidden', !undone.length);
|
||||
$('#gate').innerHTML = undone.length ? `Drill every domain once before the full exam. Not yet drilled: ${undone.join(', ')}. <a href="#" id="forceExam">Start the exam anyway</a>` : '';
|
||||
$('#optExam').disabled = !!undone.length; $('#optExam').style.opacity = undone.length ? .5 : 1;
|
||||
@ -450,6 +451,8 @@
|
||||
$('#optNext').onclick = () => start('drill', null, true);
|
||||
$('#optStudy').onclick = () => start('drill', null, true, false, null, true);
|
||||
$('#optAll').onclick = () => start('drill', null, false, true);
|
||||
$('#optSimA').onclick = () => startSim('a');
|
||||
$('#optSimB').onclick = () => startSim('b');
|
||||
$('#hardMode').onchange = () => load();
|
||||
$('#optDrill').onclick = async () => {
|
||||
const h = await api('/api/history');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user