Add lessons: untimed guided labs, one per curriculum objective, on a host of their own, in the browser and in the terminal. Each step has a check, a hint and its commands; a lesson writes no history or scores, only a done mark on its objective. CKAD 2.1-2.4 (24 steps) and all 27 CKA objectives (135 steps) have one, and `./ksim lesson audit` proves that every step fails, is done by its own commands, then passes. Add CKA tasks 132-147 (cka/bank_course.py) on what a full CKA course teaches beyond the bank: a real upgrade from v1.36, a drain under a PodDisruptionBudget, QoS and eviction, in-place resize, kube-proxy's chains, encryption at rest, an operator, and CSI expansion, snapshots and clones (ensure_csi_hostpath). Move CKA and CKS to Kubernetes v1.37 and rebuild the hosts on Ubuntu 24.04, with kubelet, kubeadm and kubectl as held packages from pkgs.k8s.io; the desktop runs Ubuntu too. Hosts gain man pages, hexdump and xxd. Fix CKS Q4, which gave 3 of 12 points untouched, and CKA Q4 and Q49, whose solutions raced their new CRD. Audited on v1.37: CKA 147/147, CKAD 105/105, CKS 109/110 (Q103, a Falco task, failed to seed once and awaits a re-audit); lessons: 135 of 135 CKA steps and 24 of 24 CKAD steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
647 lines
35 KiB
Python
647 lines
35 KiB
Python
"""CKAD lessons: guided labs, one per curriculum objective, on a cluster of your own and without a
|
|
clock. A lesson explains, then walks you through steps that you do on its host; each step has a
|
|
check, a hint and the commands that do it. It ends by sending you to the objective's timed drill.
|
|
Lessons write nothing to the history or the scores - only a "lesson done" mark on the objective.
|
|
|
|
./ksim lesson the lessons of this track, and which you have done
|
|
./ksim lesson 2.1 start the lesson of objective 2.1 (id or key)
|
|
./ksim lesson check check the step you are on; the next one follows
|
|
./ksim lesson audit [OBJ..] prove lessons: every step fails before its commands and passes after
|
|
|
|
A lesson's host is named after its id - lesson 9201 runs on `ckad9201` - which never clashes with
|
|
a task's. Its files go under {WS}/lesson-<objective>/. A step's `show` commands are what the
|
|
learner types; the audit runs exactly them, as `candidate`, so what a lesson shows always works.
|
|
|
|
Pilot (2026-10-16): the Application Deployment domain, 2.1 to 2.4.
|
|
"""
|
|
from sim.bank import NS, step
|
|
|
|
# ---- 2.1 blue/green and canary ---------------------------------------------------------------
|
|
_WEB = r"""
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: web-%{v}
|
|
labels: {app: web, version: %{v}}
|
|
spec:
|
|
replicas: 2
|
|
selector:
|
|
matchLabels: {app: web, version: %{v}}
|
|
template:
|
|
metadata:
|
|
labels: {app: web, version: %{v}}
|
|
spec:
|
|
containers:
|
|
- name: web
|
|
image: busybox:1.36
|
|
command: [sh, -c, 'mkdir -p /www && echo %{v} > /www/index.html && exec httpd -f -p 80 -h /www']
|
|
ports:
|
|
- containerPort: 80
|
|
"""
|
|
|
|
# a request through Service web from Pod client, retried while endpoints settle
|
|
_ASK = r"""
|
|
ask() { local i; for i in 1 2 3 4 5 6; do [ "$($K -n shop exec client -- wget -qO- -T 3 web 2>/dev/null)" = "$1" ] && return 0; sleep 2; done; return 1; }
|
|
"""
|
|
|
|
L21 = {
|
|
"id": 9201, "objective": "2.1", "workers": 0,
|
|
"title": "Blue/green and canary with plain Deployments",
|
|
"intro": r"""A Deployment replaces its Pods by rolling them over. Sometimes you want more control than that: run the new version **beside** the old one, try it, and decide when it gets the traffic. Nothing beyond plain Deployments and a Service is needed, because the **Service's selector** decides which Pods receive requests.
|
|
|
|
**Blue/green**: two Deployments, one Service. Changing the Service's selector moves all traffic at once, and changing it back is your rollback:
|
|
|
|
```
|
|
┌─▶ web-blue (blue)
|
|
Service web ─selector─┤
|
|
└╌▶ web-green (green)
|
|
```
|
|
|
|
**Canary**: let the Service select **both** versions, and set the split with replica counts. With 4 stable Pods and 1 canary Pod, about one request in five reaches the canary.
|
|
|
|
In Namespace `shop` you will find Deployment `web-blue` (each of its Pods answers `blue`), Service `web` in front of it, and a Pod `client` to send requests from.""",
|
|
"setup": NS + r"""
|
|
mkns shop
|
|
$K -n shop apply -f - >/dev/null <<EOF
|
|
""" + _WEB.replace("%{v}", "blue") + r"""
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: web
|
|
spec:
|
|
selector: {app: web, version: blue}
|
|
ports:
|
|
- port: 80
|
|
targetPort: 80
|
|
EOF
|
|
$K -n shop run client --image=busybox:1.36 --command -- sleep 86400 >/dev/null
|
|
$K -n shop rollout status deploy web-blue --timeout=180s >/dev/null
|
|
$K -n shop wait --for=condition=Ready pod/client --timeout=180s >/dev/null
|
|
mkdir -p "$WS/lesson-2.1"
|
|
""",
|
|
"steps": [
|
|
step("Ask the Service who answers",
|
|
r"""Before you change anything, see what users get today. The Pod `client` has `wget`, and inside the Namespace a Service is reachable by its name:
|
|
|
|
```
|
|
kubectl -n shop exec client -- wget -qO- web
|
|
```
|
|
|
|
Save the answer in `{WS}/lesson-2.1/before.txt`.""",
|
|
check=r"""tr -d '\r' < "$WS/lesson-2.1/before.txt" 2>/dev/null | grep -qx blue""",
|
|
hint=r"""`kubectl exec POD -- COMMAND` runs a command in a running Pod; `wget -qO-` prints the page instead of saving it. Pipe it through `tee FILE`, or redirect with `> FILE`.""",
|
|
show=r"""kubectl -n shop exec client -- wget -qO- web | tee {WS}/lesson-2.1/before.txt"""),
|
|
step("Start green beside blue",
|
|
r"""Create Deployment `web-green` next to `web-blue`: 2 replicas, the same container, except that each Pod answers `green`. Its Pods carry the labels `app=web` and `version=green`.
|
|
|
|
Copying blue's manifest is the quickest start: `kubectl -n shop get deploy web-blue -o yaml`. Change the name, the `version` label everywhere and the word `blue` in the command, and leave out `status`, `uid`, `resourceVersion` and `creationTimestamp`. Wait until both Pods are ready.
|
|
|
|
The Service still selects `version=blue`, so no user sees green yet: you can try it first.""",
|
|
check=r"""
|
|
eq "$(j -n shop deploy web-green '{.status.availableReplicas}')" 2 \
|
|
&& eq "$(j -n shop deploy web-green '{.spec.template.metadata.labels.app}')" web \
|
|
&& eq "$(j -n shop deploy web-green '{.spec.template.metadata.labels.version}')" green || exit 1
|
|
ip=$(j -n shop pods -l app=web,version=green '{.items[0].status.podIP}')
|
|
[ -n "$ip" ] && eq "$($K -n shop exec client -- wget -qO- -T 3 "$ip" 2>/dev/null)" green""",
|
|
hint=r"""Labels appear three times in a Deployment: `metadata.labels` (optional), `spec.selector.matchLabels` and `spec.template.metadata.labels` - the selector must match the template. `kubectl -n shop rollout status deploy web-green` waits until it is ready, and `kubectl -n shop get pods -l version=green -o wide` shows the Pod IPs to try.""",
|
|
show=r"""kubectl -n shop apply -f - <<'EOF'
|
|
""" + _WEB.replace("%{v}", "green").strip("\n") + r"""
|
|
EOF
|
|
kubectl -n shop rollout status deploy web-green
|
|
kubectl -n shop get pods -l app=web --show-labels"""),
|
|
step("Cut over to green",
|
|
r"""Point Service `web` at green. Changing the selector **is** the cutover: within a second every new request goes to green.
|
|
|
|
Careful: `kubectl set selector` replaces the **whole** selector, so name both `app` and `version`. Then ask the Service again from `client` - the answer should be `green`.""",
|
|
check=_ASK + r"""
|
|
eq "$(j -n shop svc web '{.spec.selector.app}')" web && eq "$(j -n shop svc web '{.spec.selector.version}')" green && ask green""",
|
|
hint=r"""`kubectl set selector svc web 'app=web,version=green'` - or `kubectl patch svc web -p '{"spec":{"selector":{"version":"green"}}}'`, which merges and keeps `app`. `kubectl -n shop get svc web -o wide` shows the selector.""",
|
|
show=r"""kubectl -n shop set selector svc web 'app=web,version=green'
|
|
kubectl -n shop get svc web -o wide
|
|
kubectl -n shop exec client -- wget -qO- web"""),
|
|
step("Roll back in one command",
|
|
r"""Say green misbehaves. Rolling back a blue/green release is the same move in reverse: point the Service at blue again. Blue never stopped running, so this is instant - no new rollout, no image to pull.
|
|
|
|
Switch back, and ask the Service once more.""",
|
|
check=_ASK + r"""
|
|
eq "$(j -n shop svc web '{.spec.selector.app}')" web && eq "$(j -n shop svc web '{.spec.selector.version}')" blue && ask blue""",
|
|
hint=r"""The command you used for the cutover, with `version=blue`.""",
|
|
show=r"""kubectl -n shop set selector svc web 'app=web,version=blue'
|
|
kubectl -n shop exec client -- wget -qO- web"""),
|
|
step("Canary: one Service, both versions",
|
|
r"""Now a canary. Let Service `web` select **every** Pod of the app, blue and green, by dropping `version` from its selector. Requests spread over all ready endpoints, so with 2 blue and 2 green Pods about half of them reach green.
|
|
|
|
Ask the Service a few times and watch the answers change.""",
|
|
check=r"""
|
|
eq "$(j -n shop svc web '{.spec.selector.app}')" web && [ -z "$(j -n shop svc web '{.spec.selector.version}')" ] || exit 1
|
|
for i in 1 2 3 4 5 6; do
|
|
n=$($K -n shop get endpointslices -l kubernetes.io/service-name=web -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\n"}{end}' 2>/dev/null | grep -c .)
|
|
[ "$n" -eq 4 ] && exit 0; sleep 2
|
|
done; exit 1""",
|
|
hint=r"""`kubectl set selector svc web 'app=web'` leaves only `app`. `kubectl -n shop get endpointslices -l kubernetes.io/service-name=web` lists the Pods that receive traffic.""",
|
|
show=r"""kubectl -n shop set selector svc web 'app=web'
|
|
kubectl -n shop get endpointslices -l kubernetes.io/service-name=web
|
|
for i in 1 2 3 4 5 6 7 8; do kubectl -n shop exec client -- wget -qO- web; done"""),
|
|
step("Weight the canary to 20%",
|
|
r"""Half the traffic is too much for a canary. The split follows the replica counts, so run **4** blue Pods and **1** green Pod: about one request in five reaches green.
|
|
|
|
When green has proved itself you would scale it up and blue down, then remove blue - that is the whole canary.""",
|
|
check=r"""
|
|
eq "$(j -n shop deploy web-blue '{.status.availableReplicas}')" 4 && eq "$(j -n shop deploy web-green '{.spec.replicas}')" 1 \
|
|
&& eq "$(j -n shop deploy web-green '{.status.availableReplicas}')" 1 \
|
|
&& eq "$(j -n shop svc web '{.spec.selector.app}')" web && [ -z "$(j -n shop svc web '{.spec.selector.version}')" ]""",
|
|
hint=r"""`kubectl scale deploy NAME --replicas=N`, once for each Deployment.""",
|
|
show=r"""kubectl -n shop scale deploy web-blue --replicas=4
|
|
kubectl -n shop scale deploy web-green --replicas=1
|
|
kubectl -n shop rollout status deploy web-blue
|
|
kubectl -n shop get deploy"""),
|
|
],
|
|
"outro": r"""You did a blue/green cutover, an instant rollback and a weighted canary with nothing but Deployments, labels and one Service.
|
|
|
|
**For the exam**
|
|
|
|
- The Service's selector is the switch. `kubectl get svc NAME -o wide` shows it; `kubectl get endpointslices -l kubernetes.io/service-name=NAME` shows who receives traffic.
|
|
- `kubectl set selector` replaces the whole selector; `kubectl patch` merges into it.
|
|
- Keep blue running until a task says otherwise: it is your rollback.
|
|
- A canary's share is its Pods divided by all the Pods behind the Service.
|
|
|
|
Next, prove it against the clock: **drill 2.1** gives you this objective's tasks with a timer, and no solution until a task is graded.""",
|
|
}
|
|
|
|
# ---- 2.2 rolling updates and rollbacks -------------------------------------------------------
|
|
L22 = {
|
|
"id": 9202, "objective": "2.2", "workers": 0,
|
|
"title": "Rolling updates, a bad release, and the way back",
|
|
"intro": r"""A Deployment never edits a running Pod. Change its Pod template - a new image, a new environment variable - and it creates a new ReplicaSet, then moves Pods over from the old one a few at a time. Two numbers decide how:
|
|
|
|
```
|
|
maxSurge extra Pods during an update
|
|
maxUnavailable Pods that may be missing
|
|
```
|
|
|
|
Each template change is a **revision**, and you can list revisions and go back to one.
|
|
|
|
In Namespace `rollout`, Deployment `api` runs `nginx:1.25` with 4 replicas.""",
|
|
"setup": NS + r"""
|
|
mkns rollout
|
|
$K -n rollout create deploy api --image=nginx:1.25 --replicas=4 >/dev/null
|
|
$K -n rollout rollout status deploy api --timeout=180s >/dev/null 2>&1
|
|
mkdir -p "$WS/lesson-2.2"
|
|
""",
|
|
"steps": [
|
|
step("Read the update strategy",
|
|
r"""Find the Deployment's `maxSurge` and `maxUnavailable`, and write them separated by a space to `{WS}/lesson-2.2/strategy.txt` - for example `25% 25%`.""",
|
|
check=r"""eq "$(tr -d '\r\n' < "$WS/lesson-2.2/strategy.txt" 2>/dev/null | tr -s ' ')" "25% 25%" """,
|
|
hint=r"""Both live under `.spec.strategy.rollingUpdate`. `kubectl explain deploy.spec.strategy.rollingUpdate` describes them, and `-o jsonpath='{...}'` prints only the values.""",
|
|
show=r"""kubectl -n rollout get deploy api -o jsonpath='{.spec.strategy.rollingUpdate.maxSurge} {.spec.strategy.rollingUpdate.maxUnavailable}' > {WS}/lesson-2.2/strategy.txt
|
|
cat {WS}/lesson-2.2/strategy.txt"""),
|
|
step("Never drop below four Pods",
|
|
r"""25% of 4 Pods means one Pod may be missing during an update. Make updates add a Pod before they remove one: set `maxUnavailable` to `0` and `maxSurge` to `1`.""",
|
|
check=r"""eq "$(j -n rollout deploy api '{.spec.strategy.rollingUpdate.maxUnavailable}')" 0 && eq "$(j -n rollout deploy api '{.spec.strategy.rollingUpdate.maxSurge}')" 1""",
|
|
hint=r"""A merge patch with `kubectl patch deploy api -p '{"spec":{"strategy":{...}}}'`, or `kubectl edit deploy api`.""",
|
|
show=r"""kubectl -n rollout patch deploy api -p '{"spec":{"strategy":{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0}}}}'
|
|
kubectl -n rollout get deploy api -o jsonpath='{.spec.strategy}'; echo"""),
|
|
step("Roll out nginx 1.27, with a reason",
|
|
r"""Update the container `nginx` to image `nginx:1.27`, and record why with the annotation `kubernetes.io/change-cause` set to `nginx 1.27`: `kubectl rollout history` shows it in the CHANGE-CAUSE column. Follow the update with `kubectl rollout status` until it finishes.""",
|
|
check=r"""
|
|
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:1.27 \
|
|
&& eq "$(j -n rollout deploy api '{.metadata.annotations.kubernetes\.io/change-cause}')" "nginx 1.27" \
|
|
&& eq "$(j -n rollout deploy api '{.status.updatedReplicas}')" 4 && eq "$(j -n rollout deploy api '{.status.availableReplicas}')" 4 \
|
|
&& eq "$(j -n rollout deploy api '{.status.replicas}')" 4""",
|
|
hint=r"""`kubectl set image deploy/api nginx=nginx:1.27` - the name before `=` is the **container's**. Then `kubectl annotate deploy/api kubernetes.io/change-cause='nginx 1.27'`.""",
|
|
show=r"""kubectl -n rollout set image deploy/api nginx=nginx:1.27
|
|
kubectl -n rollout annotate deploy/api kubernetes.io/change-cause='nginx 1.27' --overwrite
|
|
kubectl -n rollout rollout status deploy/api
|
|
kubectl -n rollout rollout history deploy/api"""),
|
|
step("Ship a bad release",
|
|
r"""Now a mistake: update to image `nginx:9.99-nope`, which does not exist. Run `kubectl rollout status` with `--timeout=20s` and look at the Pods: one new Pod cannot pull its image, and because of `maxUnavailable: 0` all four old Pods keep serving.
|
|
|
|
A rollout that cannot finish does not take your app down - it stalls.""",
|
|
check=r"""
|
|
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:9.99-nope || exit 1
|
|
eq "$(j -n rollout deploy api '{.status.availableReplicas}')" 4 || exit 1
|
|
for i in $(seq 1 20); do
|
|
$K -n rollout get pods -o jsonpath='{range .items[*]}{.status.containerStatuses[0].state.waiting.reason}{"\n"}{end}' 2>/dev/null \
|
|
| grep -Eq 'ErrImagePull|ImagePullBackOff' && exit 0
|
|
sleep 3
|
|
done; exit 1""",
|
|
hint=r"""`kubectl set image` once more. `kubectl get pods` shows `ErrImagePull` or `ImagePullBackOff`, and `kubectl describe pod NAME` says why.""",
|
|
show=r"""kubectl -n rollout set image deploy/api nginx=nginx:9.99-nope
|
|
kubectl -n rollout rollout status deploy/api --timeout=20s
|
|
kubectl -n rollout get pods"""),
|
|
step("Roll back",
|
|
r"""Undo the bad release: `kubectl rollout undo` returns to the previous revision, `nginx:1.27`, and the stuck Pod goes away. Wait until the rollout is complete.""",
|
|
check=r"""
|
|
eq "$(j -n rollout deploy api '{.spec.template.spec.containers[0].image}')" nginx:1.27 \
|
|
&& eq "$(j -n rollout deploy api '{.status.updatedReplicas}')" 4 && eq "$(j -n rollout deploy api '{.status.availableReplicas}')" 4 \
|
|
&& eq "$(j -n rollout deploy api '{.status.replicas}')" 4""",
|
|
hint=r"""`kubectl rollout undo deploy/api` goes back one revision; `--to-revision=N` goes back to any one that `kubectl rollout history` lists.""",
|
|
show=r"""kubectl -n rollout rollout undo deploy/api
|
|
kubectl -n rollout rollout status deploy/api"""),
|
|
step("Read the history right",
|
|
r"""List the revisions with `kubectl rollout history deploy/api`. The undo did not go back to revision 2: it made the 1.27 template the **newest** revision, and revision 2 is gone from the list.
|
|
|
|
Write the current revision number to `{WS}/lesson-2.2/revision.txt`.""",
|
|
check=r"""
|
|
cur=$(j -n rollout deploy api '{.metadata.annotations.deployment\.kubernetes\.io/revision}')
|
|
[ -n "$cur" ] && eq "$(tr -dc '0-9' < "$WS/lesson-2.2/revision.txt" 2>/dev/null)" "$cur" """,
|
|
hint=r"""The last line of `kubectl rollout history` - or the Deployment's annotation `deployment.kubernetes.io/revision`.""",
|
|
show=r"""kubectl -n rollout rollout history deploy/api
|
|
kubectl -n rollout get deploy api -o jsonpath='{.metadata.annotations.deployment\.kubernetes\.io/revision}' > {WS}/lesson-2.2/revision.txt
|
|
cat {WS}/lesson-2.2/revision.txt"""),
|
|
],
|
|
"outro": r"""You read and tuned an update strategy, rolled out a new image with a recorded reason, watched a broken release stall without an outage, and rolled it back.
|
|
|
|
**For the exam**
|
|
|
|
- `kubectl set image deploy/NAME CONTAINER=IMAGE`: the **container** name goes before `=`.
|
|
- `kubectl rollout status` waits, `rollout history` lists revisions, `rollout undo [--to-revision=N]` goes back, and `rollout pause` / `resume` gather several changes into one rollout.
|
|
- `maxUnavailable: 0` with a `maxSurge` keeps full capacity during an update, and a broken image only stalls it.
|
|
- The `kubernetes.io/change-cause` annotation is what `rollout history` shows as CHANGE-CAUSE (`--record` no longer exists).
|
|
|
|
Next, prove it against the clock: **drill 2.2**.""",
|
|
}
|
|
|
|
# ---- 2.3 Helm -----------------------------------------------------------------------------------
|
|
L23 = {
|
|
"id": 9203, "objective": "2.3", "workers": 0,
|
|
"title": "Helm: install, upgrade, roll back",
|
|
"intro": r"""Helm installs an application from a **chart**: templates plus default values. One installation is a **release**, and every change to a release - an upgrade or a rollback - is a new **revision** that you can list and return to.
|
|
|
|
```
|
|
chart ── helm install shop ──▶ revision 1
|
|
── helm upgrade shop ──▶ revision 2, 3 ..
|
|
```
|
|
|
|
In the exam a chart usually comes from a repository (`helm repo add`, `helm search repo`). Here it is local, which works the same: a chart directory or a packaged `.tgz` can go anywhere a chart name can. In `{WS}/lesson-2.3` you will find a chart `webapp` 0.1.0 as a directory, `chart/`, and version 0.2.0 packaged as `webapp-0.2.0.tgz`.""",
|
|
"setup": r"""
|
|
d="$WS/lesson-2.3"; rm -rf "$d"; mkdir -p "$d"
|
|
src=$(mktemp -d)
|
|
for v in 0.1.0 0.2.0; do
|
|
c="$src/$v/webapp"; mkdir -p "$c/templates"
|
|
cat > "$c/Chart.yaml" <<EOF
|
|
apiVersion: v2
|
|
name: webapp
|
|
description: A small web app for the Helm lesson
|
|
version: $v
|
|
appVersion: "1.27"
|
|
EOF
|
|
cat > "$c/values.yaml" <<'EOF'
|
|
replicaCount: 1
|
|
image:
|
|
repository: nginx
|
|
tag: "1.27"
|
|
service:
|
|
type: ClusterIP
|
|
port: 80
|
|
EOF
|
|
cat > "$c/templates/deployment.yaml" <<'EOF'
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: {{ .Release.Name }}-webapp
|
|
labels: {app.kubernetes.io/instance: {{ .Release.Name }}, helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}}
|
|
spec:
|
|
replicas: {{ .Values.replicaCount }}
|
|
selector:
|
|
matchLabels: {app.kubernetes.io/instance: {{ .Release.Name }}}
|
|
template:
|
|
metadata:
|
|
labels: {app.kubernetes.io/instance: {{ .Release.Name }}}
|
|
spec:
|
|
containers:
|
|
- name: web
|
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
|
ports:
|
|
- containerPort: 80
|
|
EOF
|
|
cat > "$c/templates/service.yaml" <<'EOF'
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: {{ .Release.Name }}-webapp
|
|
spec:
|
|
type: {{ .Values.service.type }}
|
|
selector: {app.kubernetes.io/instance: {{ .Release.Name }}}
|
|
ports:
|
|
- port: {{ .Values.service.port }}
|
|
targetPort: 80
|
|
EOF
|
|
done
|
|
cp -r "$src/0.1.0/webapp" "$d/chart"
|
|
helm package "$src/0.2.0/webapp" -d "$d" >/dev/null
|
|
rm -rf "$src"
|
|
command -v helm >/dev/null || echo "warn: helm is not installed on this machine - the checks need it"
|
|
""",
|
|
"steps": [
|
|
step("Look inside before you install",
|
|
r"""See what a chart would do without touching the cluster. `helm show values` lists its knobs and their defaults; `helm template` renders the manifests Helm would apply.
|
|
|
|
Render the chart in `{WS}/lesson-2.3/chart` as release `shop` into `{WS}/lesson-2.3/rendered.yaml`, and read it.""",
|
|
check=r"""f="$WS/lesson-2.3/rendered.yaml"; grep -q 'kind: Deployment' "$f" 2>/dev/null && grep -q 'kind: Service' "$f" && grep -q 'name: shop-webapp' "$f" """,
|
|
hint=r"""`helm template RELEASE CHART` prints to stdout: redirect it with `> FILE`.""",
|
|
show=r"""helm show values {WS}/lesson-2.3/chart
|
|
helm template shop {WS}/lesson-2.3/chart > {WS}/lesson-2.3/rendered.yaml
|
|
cat {WS}/lesson-2.3/rendered.yaml"""),
|
|
step("Install it",
|
|
r"""Install the chart as release `shop` into Namespace `charts` - let Helm create the Namespace - with **2** replicas instead of the default 1.""",
|
|
check=r"""
|
|
helm -n charts status shop -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
|
|
eq "$(j -n charts deploy shop-webapp '{.status.availableReplicas}')" 2""",
|
|
hint=r"""`helm install RELEASE CHART -n NAMESPACE --create-namespace --set replicaCount=2`. `helm list -n charts` shows the release.""",
|
|
show=r"""helm -n charts install shop {WS}/lesson-2.3/chart --create-namespace --set replicaCount=2
|
|
helm -n charts list
|
|
kubectl -n charts rollout status deploy shop-webapp"""),
|
|
step("Upgrade with a values file",
|
|
r"""`--set` is fine for one value; for several, a values file. Write `{WS}/lesson-2.3/prod-values.yaml` with `replicaCount: 3` and a Service of type `NodePort`, and upgrade `shop` with it.
|
|
|
|
`helm history` then lists a second revision.""",
|
|
check=r"""
|
|
grep -Eq '^replicaCount: *3' "$WS/lesson-2.3/prod-values.yaml" 2>/dev/null || exit 1
|
|
eq "$(j -n charts deploy shop-webapp '{.status.availableReplicas}')" 3 && eq "$(j -n charts svc shop-webapp '{.spec.type}')" NodePort || exit 1
|
|
[ "$(helm -n charts history shop -o json 2>/dev/null | python3 -c 'import sys,json; print(max(r["revision"] for r in json.load(sys.stdin)))')" -ge 2 ]""",
|
|
hint=r"""A values file has the shape of `values.yaml`: `service:` on one line, `type: NodePort` indented under it. Then `helm upgrade shop CHART -n charts -f FILE`.""",
|
|
show=r"""cat > {WS}/lesson-2.3/prod-values.yaml <<'EOF'
|
|
replicaCount: 3
|
|
service:
|
|
type: NodePort
|
|
EOF
|
|
helm -n charts upgrade shop {WS}/lesson-2.3/chart -f {WS}/lesson-2.3/prod-values.yaml
|
|
kubectl -n charts rollout status deploy shop-webapp
|
|
helm -n charts history shop"""),
|
|
step("A new chart version, with your values",
|
|
r"""Upgrade `shop` to chart version 0.2.0, `{WS}/lesson-2.3/webapp-0.2.0.tgz`, **keeping the values you set**.
|
|
|
|
Careful: `helm upgrade` without values starts from the chart's defaults again, so your 3 replicas and the NodePort would be gone. `--reuse-values` keeps them (or pass `-f prod-values.yaml` once more).""",
|
|
check=r"""
|
|
helm -n charts list -o json 2>/dev/null | python3 -c 'import sys,json; r=[x for x in json.load(sys.stdin) if x["name"]=="shop"]; sys.exit(0 if r and r[0]["chart"]=="webapp-0.2.0" else 1)' || exit 1
|
|
eq "$(j -n charts deploy shop-webapp '{.spec.replicas}')" 3 && eq "$(j -n charts svc shop-webapp '{.spec.type}')" NodePort""",
|
|
hint=r"""`helm upgrade shop {WS}/lesson-2.3/webapp-0.2.0.tgz -n charts --reuse-values`. `helm list -n charts` shows the chart version, `helm get values shop -n charts` the values in use.""",
|
|
show=r"""helm -n charts upgrade shop {WS}/lesson-2.3/webapp-0.2.0.tgz --reuse-values
|
|
helm -n charts list
|
|
helm -n charts get values shop"""),
|
|
step("Roll back",
|
|
r"""Go back to revision **2**: chart 0.1.0 with your production values. Look at `helm history` before and after - a rollback is a new revision too, it does not rewind the counter.""",
|
|
check=r"""
|
|
helm -n charts history shop -o json 2>/dev/null | python3 -c '
|
|
import sys, json
|
|
h = json.load(sys.stdin); last = max(h, key=lambda r: r["revision"])
|
|
sys.exit(0 if last["revision"] >= 4 and last["chart"] == "webapp-0.1.0" and last["status"] == "deployed" else 1)' || exit 1
|
|
eq "$(j -n charts deploy shop-webapp '{.spec.replicas}')" 3""",
|
|
hint=r"""`helm history shop -n charts`, then `helm rollback shop 2 -n charts`.""",
|
|
show=r"""helm -n charts history shop
|
|
helm -n charts rollback shop 2
|
|
helm -n charts history shop"""),
|
|
step("Clean up",
|
|
r"""Uninstall release `shop`. Helm deletes everything the release created - the Deployment and the Service - and its history too, unless you add `--keep-history`. The Namespace stays.""",
|
|
check=r"""! helm -n charts status shop >/dev/null 2>&1 && [ -z "$(j -n charts deploy shop-webapp '{.metadata.name}')" ] && [ -z "$(j -n charts svc shop-webapp '{.metadata.name}')" ]""",
|
|
hint=r"""`helm uninstall shop -n charts`. `helm list -a -n charts` would still show a release uninstalled with `--keep-history`.""",
|
|
show=r"""helm -n charts uninstall shop
|
|
kubectl -n charts get all"""),
|
|
],
|
|
"outro": r"""You rendered a chart, installed it, upgraded it with a values file and to a new chart version, rolled it back, and removed it.
|
|
|
|
**For the exam**
|
|
|
|
- `helm repo add NAME URL`, `helm repo update` and `helm search repo` find charts; a directory or a `.tgz` works wherever a chart name does.
|
|
- `helm show values` and `helm template` let you look before you install; `helm get values RELEASE` shows what a release runs with.
|
|
- `--set` for one value, `-f values.yaml` for several. On `upgrade`, values you do not pass go back to the chart's defaults unless you add `--reuse-values`.
|
|
- `helm history` and `helm rollback RELEASE REVISION`: every change, a rollback included, is a new revision.
|
|
- Always give the Namespace: `-n NAMESPACE`, and `--create-namespace` on install.
|
|
|
|
Next, prove it against the clock: **drill 2.3**.""",
|
|
}
|
|
|
|
# ---- 2.4 Kustomize ------------------------------------------------------------------------------
|
|
_RENDER = r"""
|
|
# rendered NAME: the overlay's objects as JSON, one per line, checked without applying anything
|
|
rendered() {
|
|
$K kustomize "$WS/lesson-2.4/overlays/$1" 2>/dev/null | $K create --dry-run=client -f - -o json 2>/dev/null | python3 -c '
|
|
import sys, json
|
|
t, dec, i = sys.stdin.read(), json.JSONDecoder(), 0
|
|
while True: # one List, or one JSON document per object - kubectl has printed both
|
|
while i < len(t) and t[i].isspace(): i += 1
|
|
if i >= len(t): break
|
|
o, i = dec.raw_decode(t, i)
|
|
for x in (o.get("items", []) if o.get("kind") == "List" else [o]): print(json.dumps(x))'
|
|
}
|
|
"""
|
|
|
|
L24 = {
|
|
"id": 9204, "objective": "2.4", "workers": 0,
|
|
"title": "Kustomize: one base, two environments",
|
|
"intro": r"""Kustomize builds plain YAML from a directory that holds a `kustomization.yaml`. There are no templates and no variables: a **base** holds the manifests, and an **overlay** points at the base and changes it - Namespace, names, labels, images, replicas, or anything else through a patch - without editing it.
|
|
|
|
```
|
|
base/ web: Deployment + Service
|
|
overlays/staging/ staging-web in staging
|
|
overlays/prod/ prod-web in prod, 3 x 1.27
|
|
```
|
|
|
|
`kubectl` has Kustomize built in: `kubectl kustomize DIR` renders, `kubectl apply -k DIR` applies. In `{WS}/lesson-2.4/base` you will find a Deployment `web` (`nginx:1.25`, 1 replica), a Service `web` and their `kustomization.yaml`. The Namespaces `staging` and `prod` exist already.""",
|
|
"setup": NS + r"""
|
|
mkns staging; mkns prod
|
|
d="$WS/lesson-2.4"; rm -rf "$d"; mkdir -p "$d/base"
|
|
cat > "$d/base/deployment.yaml" <<'EOF'
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: web
|
|
labels: {app: web}
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels: {app: web}
|
|
template:
|
|
metadata:
|
|
labels: {app: web}
|
|
spec:
|
|
containers:
|
|
- name: web
|
|
image: nginx:1.25
|
|
ports:
|
|
- containerPort: 80
|
|
EOF
|
|
cat > "$d/base/service.yaml" <<'EOF'
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: web
|
|
spec:
|
|
selector: {app: web}
|
|
ports:
|
|
- port: 80
|
|
targetPort: 80
|
|
EOF
|
|
cat > "$d/base/kustomization.yaml" <<'EOF'
|
|
resources:
|
|
- deployment.yaml
|
|
- service.yaml
|
|
EOF
|
|
( cd "$d/base" && sha256sum deployment.yaml service.yaml kustomization.yaml ) > "$PRIV/lesson-2.4-base.sum"
|
|
""",
|
|
"steps": [
|
|
step("Render the base",
|
|
r"""See what Kustomize makes of the base before you change anything: render `{WS}/lesson-2.4/base` into `{WS}/lesson-2.4/base.yaml`, and read it.""",
|
|
check=r"""f="$WS/lesson-2.4/base.yaml"; grep -q 'kind: Deployment' "$f" 2>/dev/null && grep -q 'kind: Service' "$f" && grep -q 'image: nginx:1.25' "$f" """,
|
|
hint=r"""`kubectl kustomize DIR` prints the result - the same as `kustomize build DIR`. Redirect it with `> FILE`.""",
|
|
show=r"""kubectl kustomize {WS}/lesson-2.4/base > {WS}/lesson-2.4/base.yaml
|
|
cat {WS}/lesson-2.4/base.yaml"""),
|
|
step("An overlay for staging",
|
|
r"""Create `{WS}/lesson-2.4/overlays/staging/kustomization.yaml`. It uses the base `../../base`, puts everything into Namespace `staging`, prefixes every name with `staging-`, and gives every resource the label `env: staging`.
|
|
|
|
For the label, use `labels:` with `includeSelectors: true`, which adds it to the selectors too. The older `commonLabels` does the same but is deprecated. Render the overlay to check it.""",
|
|
check=_RENDER + r"""
|
|
rendered staging | python3 -c '
|
|
import sys, json
|
|
items = [json.loads(line) for line in sys.stdin if line.strip()]
|
|
by = {(o["kind"], o["metadata"]["name"]): o for o in items}
|
|
dep, svc = by.get(("Deployment", "staging-web")), by.get(("Service", "staging-web"))
|
|
ok = bool(dep and svc) and all(o["metadata"].get("namespace") == "staging" and o["metadata"].get("labels", {}).get("env") == "staging" for o in (dep, svc))
|
|
sys.exit(0 if ok and dep["spec"]["template"]["metadata"]["labels"].get("env") == "staging" else 1)'""",
|
|
hint=r"""```
|
|
resources:
|
|
- ../../base
|
|
namespace: staging
|
|
namePrefix: staging-
|
|
labels:
|
|
- pairs:
|
|
env: staging
|
|
includeSelectors: true
|
|
```""",
|
|
show=r"""mkdir -p {WS}/lesson-2.4/overlays/staging
|
|
cat > {WS}/lesson-2.4/overlays/staging/kustomization.yaml <<'EOF'
|
|
resources:
|
|
- ../../base
|
|
namespace: staging
|
|
namePrefix: staging-
|
|
labels:
|
|
- pairs:
|
|
env: staging
|
|
includeSelectors: true
|
|
EOF
|
|
kubectl kustomize {WS}/lesson-2.4/overlays/staging"""),
|
|
step("Apply staging",
|
|
r"""Apply the staging overlay with kubectl's built-in Kustomize, then look at what you got.""",
|
|
check=r"""
|
|
eq "$(j -n staging deploy staging-web '{.status.availableReplicas}')" 1 && eq "$(j -n staging deploy staging-web '{.metadata.labels.env}')" staging \
|
|
&& [ -n "$(j -n staging svc staging-web '{.metadata.name}')" ]""",
|
|
hint=r"""`kubectl apply -k DIR` - `-k` instead of `-f`.""",
|
|
show=r"""kubectl apply -k {WS}/lesson-2.4/overlays/staging
|
|
kubectl -n staging get deploy,svc --show-labels"""),
|
|
step("A prod overlay: replicas and image",
|
|
r"""Production differs in more than its name. Create the overlay `{WS}/lesson-2.4/overlays/prod`: the same base, Namespace `prod`, prefix `prod-`, label `env: prod`, **3** replicas and image `nginx:1.27`.
|
|
|
|
Use Kustomize's own fields for the last two, `replicas:` and `images:` - do not copy the Deployment. Render it to check.""",
|
|
check=_RENDER + r"""
|
|
rendered prod | python3 -c '
|
|
import sys, json
|
|
items = [json.loads(line) for line in sys.stdin if line.strip()]
|
|
by = {(o["kind"], o["metadata"]["name"]): o for o in items}
|
|
dep = by.get(("Deployment", "prod-web"))
|
|
ok = bool(dep) and dep["metadata"].get("namespace") == "prod" and dep["metadata"].get("labels", {}).get("env") == "prod"
|
|
ok = ok and dep["spec"].get("replicas") == 3 and dep["spec"]["template"]["spec"]["containers"][0]["image"] == "nginx:1.27"
|
|
sys.exit(0 if ok and ("Service", "prod-web") in by else 1)'""",
|
|
hint=r"""Besides `resources`, `namespace`, `namePrefix` and `labels`:
|
|
|
|
```
|
|
replicas:
|
|
- name: web
|
|
count: 3
|
|
images:
|
|
- name: nginx
|
|
newTag: "1.27"
|
|
```
|
|
|
|
`replicas` names the Deployment by its base name; `images` names the image, not the container.""",
|
|
show=r"""mkdir -p {WS}/lesson-2.4/overlays/prod
|
|
cat > {WS}/lesson-2.4/overlays/prod/kustomization.yaml <<'EOF'
|
|
resources:
|
|
- ../../base
|
|
namespace: prod
|
|
namePrefix: prod-
|
|
labels:
|
|
- pairs:
|
|
env: prod
|
|
includeSelectors: true
|
|
replicas:
|
|
- name: web
|
|
count: 3
|
|
images:
|
|
- name: nginx
|
|
newTag: "1.27"
|
|
EOF
|
|
kubectl kustomize {WS}/lesson-2.4/overlays/prod"""),
|
|
step("Patch what the fields cannot reach",
|
|
r"""For anything else there is a patch. Give the `web` container in prod a memory limit of `128Mi` with a patch file in the prod overlay, listed under `patches:` - then apply the prod overlay.""",
|
|
check=r"""
|
|
eq "$(j -n prod deploy prod-web '{.status.availableReplicas}')" 3 \
|
|
&& eq "$(j -n prod deploy prod-web '{.spec.template.spec.containers[0].image}')" nginx:1.27 \
|
|
&& eq "$(j -n prod deploy prod-web '{.spec.template.spec.containers[0].resources.limits.memory}')" 128Mi \
|
|
&& eq "$(j -n prod deploy prod-web '{.metadata.labels.env}')" prod""",
|
|
hint=r"""A strategic-merge patch looks like a small Deployment that names only what changes - by the **base** name `web`:
|
|
|
|
```
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: web
|
|
spec:
|
|
template:
|
|
spec:
|
|
containers:
|
|
- name: web
|
|
resources:
|
|
limits:
|
|
memory: 128Mi
|
|
```
|
|
|
|
and in `kustomization.yaml`: `patches:` with `- path: limits.yaml`.""",
|
|
show=r"""cat > {WS}/lesson-2.4/overlays/prod/limits.yaml <<'EOF'
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: web
|
|
spec:
|
|
template:
|
|
spec:
|
|
containers:
|
|
- name: web
|
|
resources:
|
|
limits:
|
|
memory: 128Mi
|
|
EOF
|
|
cat >> {WS}/lesson-2.4/overlays/prod/kustomization.yaml <<'EOF'
|
|
patches:
|
|
- path: limits.yaml
|
|
EOF
|
|
kubectl apply -k {WS}/lesson-2.4/overlays/prod
|
|
kubectl -n prod rollout status deploy prod-web"""),
|
|
step("One base, two environments",
|
|
r"""Look at both environments side by side: `kubectl get deploy -A -l app=web -o wide`. They come from one base that you never edited - the exam checks that too.
|
|
|
|
Write the image that **staging** runs to `{WS}/lesson-2.4/staging-image.txt`.""",
|
|
check=r"""
|
|
( cd "$WS/lesson-2.4/base" && sha256sum -c --quiet "$PRIV/lesson-2.4-base.sum" >/dev/null 2>&1 ) || exit 1
|
|
eq "$(tr -d ' \r\n' < "$WS/lesson-2.4/staging-image.txt" 2>/dev/null)" nginx:1.25 || exit 1
|
|
[ -n "$(j -n staging deploy staging-web '{.metadata.name}')" ] && [ -n "$(j -n prod deploy prod-web '{.metadata.name}')" ]""",
|
|
hint=r"""`-o jsonpath='{.spec.template.spec.containers[0].image}'` prints just the image.""",
|
|
show=r"""kubectl get deploy -A -l app=web -o wide
|
|
kubectl -n staging get deploy staging-web -o jsonpath='{.spec.template.spec.containers[0].image}' > {WS}/lesson-2.4/staging-image.txt
|
|
cat {WS}/lesson-2.4/staging-image.txt"""),
|
|
],
|
|
"outro": r"""You rendered a base, built two overlays on it with Kustomize's own fields and a patch, and applied both - and the base stayed as it was.
|
|
|
|
**For the exam**
|
|
|
|
- `kubectl kustomize DIR` renders, `kubectl apply -k DIR` applies, `kubectl delete -k DIR` removes.
|
|
- An overlay lists its base under `resources:` and never edits it.
|
|
- Built-in fields first - `namespace`, `namePrefix` / `nameSuffix`, `labels`, `images`, `replicas` - and a patch for anything else. A patch names objects by their **base** names.
|
|
- `configMapGenerator` and `secretGenerator` make ConfigMaps and Secrets whose names change with their content, so a Deployment that uses one rolls when the data changes - drill 2.4 has one.
|
|
|
|
Next, prove it against the clock: **drill 2.4**.""",
|
|
}
|
|
|
|
LESSONS = [L21, L22, L23, L24]
|