kubestronaut-sim/ckad/lessons.py
vumas3r e852419130 feat: teach a domain, its lessons back to back
Add runs of a domain's lessons: ./ksim lesson ARCH, the lobby's Teach
a domain card, or the Teach a domain session in the Start exam
dialog. A run is one lesson session over the domain's lessons not
done yet (--all: every one), each on its own host, all made before it
starts, so there is no waiting between lessons. The session keeps
the lesson you are on and each lesson's checks. A lesson's wrap-up
leads to the next, the list at the top of a lesson goes to any lesson
of the run, and ./ksim lesson next and ./ksim lesson OBJ do the same
in a terminal. Finishing the run sends you to the domain's drill.

Stamp a lesson done the moment its last step passes, not when the
session ends, so terminating a lesson or a run keeps what it taught.

Tested on a real STOR run in a copy of the repo: every host up before
step 1; lesson 1.1 done step by step through the CLI and the web API,
and stamped done as its last step passed; next, jump, a lesson
outside the run refused, and the finish report as expected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:14:08 -06:00

648 lines
36 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 DEPLOY the domain's lessons not done yet, back to back; ./ksim lesson next moves on
./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]