421 lines
16 KiB
Markdown
421 lines
16 KiB
Markdown
# CKA Drill Pack v1 — killer-style, self-validating
|
||
|
||
> Range: kind cluster `drills` (1 CP + 2 workers, Calico, Gateway API CRDs, metrics-server, helm).
|
||
> Protocol per drill: paste SETUP blindly → start clock → solve TASK → run VALIDATE → paste commands + time + validation output to Claude for grading.
|
||
> Rules: imperative first where a generator exists. Verify before declaring done. Budget is a ceiling, not a goal.
|
||
> Cleanup after each drill: `k delete ns <drill-ns> --wait=false` unless noted.
|
||
|
||
---
|
||
|
||
## K1 — NetworkPolicy: same-label, wrong-namespace trap — 8 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
k create ns drill-np
|
||
k -n drill-np run db --image=nginx --labels=tier=db --port=80
|
||
k -n drill-np run api --image=nginx --labels=tier=api --port=80
|
||
k -n drill-np run web --image=nginx --labels=tier=web --port=80
|
||
k create ns drill-ext
|
||
k -n drill-ext run outsider --image=busybox --labels=tier=api -- sleep 3600
|
||
k -n drill-np wait --for=condition=Ready pod --all --timeout=60s
|
||
```
|
||
|
||
**TASK**
|
||
In Namespace `drill-np` create a NetworkPolicy `db-guard`: incoming traffic to Pods labeled `tier: db` allowed **only** on port 80 and **only** from Pods labeled `tier: api` **in the same Namespace**. All other ingress to db Pods blocked. Egress from db Pods unrestricted.
|
||
|
||
**Solution**
|
||
|
||
```bash
|
||
cat 1-netpol.yaml
|
||
---
|
||
apiVersion: networking.k8s.io/v1
|
||
kind: NetworkPolicy
|
||
metadata:
|
||
name: db-guard
|
||
namespace: drill-np
|
||
spec:
|
||
podSelector:
|
||
matchLabels:
|
||
tier: db
|
||
policyTypes:
|
||
- Ingress
|
||
ingress:
|
||
- from:
|
||
- podSelector:
|
||
matchLabels:
|
||
tier: api
|
||
ports:
|
||
- protocol: TCP
|
||
port: 80
|
||
```
|
||
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
DB_IP=$(k -n drill-np get pod db -o jsonpath='{.status.podIP}')
|
||
k -n drill-np exec api -- curl -s -m 2 $DB_IP # nginx HTML (allowed)
|
||
k -n drill-np exec web -- curl -s -m 2 $DB_IP # timeout / exit 28 (blocked)
|
||
k -n drill-ext exec outsider -- wget -qO- -T 2 $DB_IP # timeout (blocked — same label, wrong ns)
|
||
```
|
||
Third check is the point: if it succeeds, your `from` clause selects by pod label without pinning the namespace.
|
||
|
||
---
|
||
|
||
## K2 — HPA: scale-up rate limiting — 7 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
k create ns drill-hpa
|
||
k -n drill-hpa create deploy checkout --image=nginx --replicas=2
|
||
k -n drill-hpa set resources deploy checkout --requests=cpu=100m
|
||
```
|
||
|
||
**TASK**
|
||
Create HPA `checkout-hpa` for Deployment `checkout` in `drill-hpa`: min 2, max 6, target average CPU utilization 65%. Scale-**up** limited to at most 1 Pod per 120 seconds. (Up, not down — read your policy block twice.)
|
||
|
||
**Solution**
|
||
```bash
|
||
cat 2-hpa.yaml | k neat
|
||
apiVersion: autoscaling/v2
|
||
kind: HorizontalPodAutoscaler
|
||
metadata:
|
||
name: checkout-hpa
|
||
namespace: drill-hpa
|
||
spec:
|
||
maxReplicas: 6
|
||
metrics:
|
||
- resource:
|
||
name: cpu
|
||
target:
|
||
averageUtilization: 60
|
||
type: Utilization
|
||
type: Resource
|
||
minReplicas: 2
|
||
scaleTargetRef:
|
||
apiVersion: apps/v1
|
||
kind: Deployment
|
||
name: checkout
|
||
```
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
k -n drill-hpa get hpa checkout-hpa -o yaml | grep -A8 behavior
|
||
# scaleUp → policies: [{type: Pods, value: 1, periodSeconds: 120}]
|
||
k -n drill-hpa get hpa checkout-hpa -o jsonpath='{.spec.metrics[0].resource.target.averageUtilization}' # 65
|
||
k -n drill-hpa get hpa checkout-hpa # TARGETS: cpu%/65% (or <unknown> without metrics-server)
|
||
```
|
||
|
||
---
|
||
|
||
## K3 — HTTPRoute: header AND/OR semantics — 10 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
# cluster prerequisities - GatewayApi
|
||
GWAPI=v1.3.0 # match to what your Cilium minor supports; check the Cilium docs for your version
|
||
|
||
# Standard channel: GatewayClass, Gateway, HTTPRoute, GRPCRoute, ReferenceGrant
|
||
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/${GWAPI}/standard-install.yaml
|
||
|
||
# Cilium additionally expects TLSRoute, which lives only in the experimental channel
|
||
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/${GWAPI}/config/crd/experimental/gateway.networking.k8s.io_tlsroutes.yaml
|
||
|
||
kubectl get crd | grep gateway.networking.k8s.io
|
||
|
||
|
||
# Step 2 — Enable Gateway API in Cilium
|
||
helm repo update cilium
|
||
|
||
helm upgrade cilium cilium/cilium -n kube-system \
|
||
--reuse-values \
|
||
--set gatewayAPI.enabled=true
|
||
|
||
# confirm the flag reached the ConfigMap
|
||
kubectl -n kube-system get cm cilium-config -o jsonpath='{.data.enable-gateway-api}{"\n"}' # true
|
||
|
||
## Step 3 — Restart operator and agents (REQUIRED — see Context)
|
||
kubectl -n kube-system rollout restart deploy/cilium-operator
|
||
kubectl -n kube-system rollout restart ds/cilium
|
||
|
||
kubectl -n kube-system rollout status deploy/cilium-operator --timeout=180s
|
||
kubectl -n kube-system rollout status ds/cilium --timeout=300s
|
||
|
||
|
||
kubectl create ns drill-gw
|
||
|
||
kubectl -n drill-gw create deploy legacy --image=hashicorp/http-echo -- /http-echo -text=legacy -listen=:8080
|
||
kubectl -n drill-gw create deploy beta --image=hashicorp/http-echo -- /http-echo -text=beta -listen=:8080
|
||
kubectl -n drill-gw expose deploy legacy --port=8080
|
||
kubectl -n drill-gw expose deploy beta --port=8080
|
||
kubectl -n drill-gw rollout status deploy/legacy deploy/beta
|
||
|
||
kubectl apply -f - <<'EOF'
|
||
apiVersion: gateway.networking.k8s.io/v1
|
||
kind: Gateway
|
||
metadata: {name: drill-gate, namespace: drill-gw}
|
||
spec:
|
||
gatewayClassName: cilium
|
||
listeners:
|
||
- {name: http, port: 80, protocol: HTTP, allowedRoutes: {namespaces: {from: Same}}}
|
||
EOF
|
||
|
||
kubectl -n drill-gw wait --for=condition=Programmed gateway/drill-gate --timeout=180s
|
||
kubectl -n drill-gw get gateway drill-gate
|
||
```
|
||
|
||
**TASK**
|
||
Create HTTPRoute `canary-route` in `drill-gw` attached to Gateway `drill-gate`:
|
||
1. `/app` (prefix) with header `X-Canary` exactly `true` → Service `beta:8080`
|
||
2. all other `/app` → `legacy:8080`
|
||
3. `/app` with `X-Canary: true` AND query param `debug=1` must ALSO reach `beta` — third rule or covered already? Justify in one sentence.
|
||
|
||
**VALIDATE** (spec-level — dummy class, no dataplane)
|
||
```bash
|
||
k -n drill-gw get httproute canary-route -o yaml
|
||
# 1: header+path in ONE match entry (AND), not two entries (OR)
|
||
# 2: header rule ordered/precedent over bare /app
|
||
k -n drill-gw get httproute canary-route -o jsonpath='{.spec.parentRefs[0].name}' # drill-gate
|
||
```
|
||
|
||
---
|
||
|
||
## K4 — Helm: pin, override, upgrade without losing values — 6 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
helm repo add bitnami https://charts.bitnami.com/bitnami 2>/dev/null; helm repo update
|
||
```
|
||
|
||
**TASK**
|
||
Install `bitnami/nginx` as release `web-drill` in new namespace `drill-helm`, **exactly 2 replicas** set via CLI (no values file), chart version pinned one minor **behind** latest. Print the user-supplied values of the deployed release. Upgrade to latest chart version **keeping** the replica override.
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
helm list -n drill-helm # pinned version, then latest after upgrade
|
||
helm get values web-drill -n drill-helm # replicaCount: 2 — must survive the upgrade
|
||
k -n drill-helm get deploy # READY 2/2
|
||
helm history web-drill -n drill-helm # rev1 install, rev2 upgrade
|
||
```
|
||
Trap: "keeping your override" — there is a right flag and a wrong assumption.
|
||
|
||
---
|
||
|
||
## K5 — Kustomize: overlay surgery (extended) — 14 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
mkdir -p /tmp/drill-kz/base /tmp/drill-kz/overlays/prod
|
||
cat > /tmp/drill-kz/base/deploy.yaml <<'EOF'
|
||
apiVersion: apps/v1
|
||
kind: Deployment
|
||
metadata:
|
||
name: portal
|
||
labels: {app: portal}
|
||
spec:
|
||
replicas: 1
|
||
selector: {matchLabels: {app: portal}}
|
||
template:
|
||
metadata: {labels: {app: portal}}
|
||
spec:
|
||
containers:
|
||
- name: portal
|
||
image: nginx:1.25
|
||
envFrom:
|
||
- configMapRef: {name: portal-config}
|
||
EOF
|
||
cat > /tmp/drill-kz/base/svc.yaml <<'EOF'
|
||
apiVersion: v1
|
||
kind: Service
|
||
metadata:
|
||
name: portal
|
||
labels: {app: portal}
|
||
spec:
|
||
selector: {app: portal}
|
||
ports:
|
||
- {port: 80, targetPort: 80}
|
||
EOF
|
||
cat > /tmp/drill-kz/base/kustomization.yaml <<'EOF'
|
||
resources: [deploy.yaml, svc.yaml]
|
||
configMapGenerator:
|
||
- name: portal-config
|
||
literals: [APP_MODE=basic]
|
||
EOF
|
||
```
|
||
|
||
**TASK**
|
||
Build overlay `prod` at `/tmp/drill-kz/overlays/prod` that, without touching base, does ALL of the following:
|
||
|
||
1. Sets namespace `drill-prod` on everything.
|
||
2. Adds name prefix `prod-` to every resource.
|
||
3. Adds common label `env: prod` — must land on the Deployment's metadata, its pod template, **and** its selector/Service selector (labels + selectors stay consistent).
|
||
4. Bumps the image to `nginx:1.27` (use the `images` transformer, not a patch).
|
||
5. Scales replicas to 3 (use the `replicas` transformer, not a patch).
|
||
6. **Extends** the `portal-config` ConfigMap with `LOG_LEVEL=debug` while **keeping** `APP_MODE=basic` — this must be a generator merge, not a redefinition.
|
||
7. Adds a **new** `portal-secret` Secret generator (literal `API_KEY=s3cr3t`) and wires it into the container's `envFrom` via a **JSON 6902** patch — reference the secret by its plain generator name and let kustomize's name-reference fixup resolve the real (hashed, prefixed) name for you.
|
||
8. Adds container resource limits `cpu: 200m`, `memory: 128Mi` via the same or a second JSON 6902 patch (`resources` isn't set in base — the op must `add`, not `replace`).
|
||
|
||
Create namespace `drill-prod`, apply the overlay with `-k`.
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
kubectl kustomize /tmp/drill-kz/overlays/prod # eyeball everything before applying — 4 objects: Deployment, Service, ConfigMap, Secret
|
||
|
||
k -n drill-prod get deploy prod-portal -o jsonpath='{.spec.replicas} {.spec.template.spec.containers[0].image}'
|
||
# 3 nginx:1.27
|
||
|
||
k -n drill-prod get deploy prod-portal -o jsonpath='{.metadata.labels.env} {.spec.selector.matchLabels.env} {.spec.template.metadata.labels.env}'
|
||
# prod prod prod (object label + selector + template label all consistent)
|
||
|
||
CM=$(k -n drill-prod get cm -o name | grep prod-portal-config)
|
||
k -n drill-prod get $CM -o jsonpath='{.data}'
|
||
# {"APP_MODE":"basic","LOG_LEVEL":"debug"} — both keys present, merge not replace
|
||
|
||
k -n drill-prod get secret -o name | grep prod-portal-secret # generated, hashed, prefixed
|
||
|
||
k -n drill-prod get deploy prod-portal -o jsonpath='{.spec.template.spec.containers[0].envFrom}'
|
||
# two entries — configMapRef and secretRef both pointing at the REAL hashed+prefixed names
|
||
# if secretRef.name literally reads "portal-secret", your patch bypassed kustomize's name-reference transformer
|
||
|
||
k -n drill-prod get deploy prod-portal -o jsonpath='{.spec.template.spec.containers[0].resources.limits}'
|
||
# {"cpu":"200m","memory":"128Mi"}
|
||
```
|
||
Trap: item 7 is the point of the drill — patch against the *pre-transform* name and trust kustomize to rewrite it everywhere; hand-typing the hashed name works today and silently breaks the next time a literal changes.
|
||
|
||
---
|
||
|
||
## K6 — CRD + CR: author the schema — 10 min
|
||
|
||
**SETUP** — none. Blank slate is the drill.
|
||
|
||
**TASK**
|
||
Create CRD `backups.data.example.com`: namespaced, kind `Backup`, plural `backups`, shortName `bk`, version `v1` (served+storage). Schema: `spec.source` (string, required), `spec.retentionDays` (integer, 1–30). Then create a `Backup` named `nightly` in namespace `drill-crd` with source `pg-main`, retentionDays 7. Then prove validation works: attempt retentionDays 99 and capture the rejection.
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
k api-resources | grep backups # data.example.com, bk, Backup
|
||
k explain backup.spec # both fields, with types
|
||
k -n drill-crd get bk nightly -o jsonpath='{.spec.retentionDays}' # 7
|
||
# the 99 attempt must fail with a schema validation error — paste the error line
|
||
```
|
||
|
||
---
|
||
|
||
## K7 — RBAC + SA + API curl: the 403 chain — 10 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
k create ns drill-rbac
|
||
k -n drill-rbac create sa auditor
|
||
k -n drill-rbac run client --image=nginx:1-alpine --overrides='{"spec":{"serviceAccountName":"auditor"}}'
|
||
```
|
||
|
||
**TASK**
|
||
1. Grant SA `auditor` permission to **get and list** ConfigMaps in `drill-rbac` only — imperative commands only, no YAML files.
|
||
2. Prove with `kubectl auth can-i` (positive AND negative: it must NOT list secrets).
|
||
3. Exec into `client` and curl the ConfigMaps list endpoint with the mounted token — capture the HTTP response kind.
|
||
4. Curl the Secrets endpoint the same way — capture the 403.
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
k auth can-i list configmaps --as=system:serviceaccount:drill-rbac:auditor -n drill-rbac # yes
|
||
k auth can-i list secrets --as=system:serviceaccount:drill-rbac:auditor -n drill-rbac # no
|
||
# curl 1 → "kind":"ConfigMapList"
|
||
# curl 2 → "kind":"Status","code":403
|
||
```
|
||
|
||
---
|
||
|
||
## K8 — Scheduling: taints, tolerations, spread — 9 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
k taint node dev-cilium-worker dedicated=batch:NoSchedule
|
||
k label node dev-cilium-worker pool=batch
|
||
k label node dev-cilium-worker2 pool=web
|
||
```
|
||
|
||
**TASK**
|
||
1. Create deployment `batch-runner` (image `busybox`, command `sleep 3600`, 2 replicas) in new ns `drill-sched` that runs **only** on `drills-worker` — it must tolerate the taint AND be constrained to that node via its label (both mechanisms, and know why one alone is insufficient).
|
||
2. Create pod `web-pin` (nginx) that must land on `drills-worker2` using nodeAffinity (no nodeName).
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
k -n drill-sched get pods -o wide # both batch-runner pods on drills-worker, web-pin on drills-worker2
|
||
k -n drill-sched get deploy batch-runner -o yaml | grep -A6 tolerations # dedicated=batch:NoSchedule tolerated
|
||
k -n drill-sched get pod web-pin -o yaml | grep -A10 affinity # nodeAffinity on pool=web
|
||
```
|
||
Question to answer in your paste: what happens with toleration but no selector? (One sentence.)
|
||
|
||
**CLEANUP** (mandatory — taints leak into later drills)
|
||
```bash
|
||
k taint node drills-worker dedicated=batch:NoSchedule-
|
||
k label node drills-worker pool- ; k label node drills-worker2 pool-
|
||
```
|
||
|
||
---
|
||
|
||
## K9 — Storage: PV/PVC binding chain — 8 min
|
||
|
||
**SETUP**
|
||
```bash
|
||
k apply -f - <<'EOF'
|
||
apiVersion: storage.k8s.io/v1
|
||
kind: StorageClass
|
||
metadata: {name: drill-manual}
|
||
provisioner: kubernetes.io/no-provisioner
|
||
volumeBindingMode: WaitForFirstConsumer
|
||
EOF
|
||
```
|
||
|
||
**TASK**
|
||
1. Create PV `pv-drill`: 1Gi, `ReadWriteOnce`, storageClassName `drill-manual`, hostPath `/tmp/pv-drill`.
|
||
2. Create PVC `pvc-drill` in new ns `drill-store`: request 500Mi, same class, RWO.
|
||
3. Explain (one sentence) why the PVC stays `Pending` right now — this is not an error.
|
||
4. Create pod `store-user` (nginx) mounting the PVC at `/data`; confirm the bind flips.
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
k -n drill-store get pvc pvc-drill # Pending BEFORE pod, Bound AFTER
|
||
k get pv pv-drill # Bound, CLAIM drill-store/pvc-drill
|
||
k -n drill-store exec store-user -- sh -c 'echo ok > /data/probe && cat /data/probe' # ok
|
||
```
|
||
|
||
---
|
||
|
||
## K10 — Native sidecar + output formatting — 8 min
|
||
|
||
**SETUP** — none.
|
||
|
||
**TASK**
|
||
1. In new ns `drill-side`, create deployment `tracked-app` (1 replica): main container `app` (busybox) appends the date to `/var/log/app/beat.log` every 5s; **native sidecar** `tail-agent` (busybox) — init container with `restartPolicy: Always` — tails that file. Shared emptyDir at `/var/log/app`.
|
||
2. One command: print for every pod in `drill-side` the columns `NAME`, `QOS`, `NODE`, sorted by name, written to `/tmp/drill-side.txt`.
|
||
|
||
**VALIDATE**
|
||
```bash
|
||
k -n drill-side get pod -o jsonpath='{.items[0].spec.initContainers[0].restartPolicy}' # Always
|
||
k -n drill-side logs deploy/tracked-app -c tail-agent | tail -3 # dated heartbeats flowing
|
||
cat /tmp/drill-side.txt # header row + one line: name, QoS class, node
|
||
```
|
||
|
||
---
|
||
|
||
## Grading rubric (what Claude checks on every paste-back)
|
||
1. Correct end state (validation output matches expected)
|
||
2. Path efficiency — generator/patch/docs-copy vs hand-written YAML
|
||
3. Verification discipline — did you run VALIDATE before declaring done
|
||
4. Time vs budget
|
||
5. Trap detection — each drill has one; naming it explicitly earns full marks
|
||
|
||
## Score log
|
||
| Drill | Date | Time | Clean? | Trap caught? | Notes |
|
||
|---|---|---|---|---|---|
|
||
| K1 | | | | | |
|
||
| K2 | | | | | |
|
||
| K3 | | | | | |
|
||
| K4 | | | | | |
|
||
| K5 | | | | | |
|
||
| K6 | | | | | |
|
||
| K7 | | | | | |
|
||
| K8 | | | | | |
|
||
| K9 | | | | | |
|
||
| K10 | | | | | | |