kubestronaut-sim/cka/lessons_storage.py
vumas3r 226ef19ff9 feat: show every CKA lesson step the way the exam is done
Show me how gave the commands the audit runs: sed, awk and yq edits,
heredoc manifests, JSON patches, --overrides, sleeps and wait loops -
nothing anyone types in the exam. A step's show is now a list of
parts (sim/bank.py): cmd() is what you type as it stands; hand() is
what you do by hand - vim on a generated skeleton or on a manifest
pasted from the docs page it names, kubectl edit, ssh NODE and sudo
-i, a value you read and echo - with a script that does the same for
the audit. All 135 CKA steps are rewritten so, with k for kubectl;
their checks, titles and texts are unchanged.

Add ./ksim lesson lint, which reads what every step has you type and
flags scripts. The lesson audit runs it first.

Record the hosts whose setup failed when a session started. The page
warns above that task or lesson and offers Rebuild this host; the CLI
names ./ksim reset N on each step.

Render *italics* in lessons and tasks, outside code spans.

Audited on v1.37: CKA lessons 27/27, 135/135 steps, done the exam
way; lint clean. kubectl edit --subresource resize, the one edit the
audit replaces with a patch, was proven on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:34:36 -06:00

765 lines
40 KiB
Python

"""CKA lessons of the Storage domain (1.1-1.3). See cka/lessons.py for what a lesson is."""
from sim.bank import NS, step, cmd, hand
# ---- 1.1 StorageClasses and dynamic provisioning ---------------------------------------------
L11 = {
"id": 9101, "objective": "1.1", "workers": 0,
"title": "StorageClasses: who makes the volume, when, and what happens after",
"intro": r"""Nobody creates PersistentVolumes by hand on a cluster with a **provisioner**. A claim names a **StorageClass**, and the provisioner behind that class makes a volume to fit the claim. The class decides three things you will be asked about:
```
provisioner who makes the volume rancher.io/local-path, ebs.csi.aws.com, ...
volumeBindingMode when it is made Immediate | WaitForFirstConsumer
reclaimPolicy what happens when the claim goes Delete | Retain
```
`WaitForFirstConsumer` holds a claim `Pending` until a Pod uses it, so the volume is made on the node the Pod lands on. `Immediate` provisions at once, wherever. The reclaim policy is copied into each volume when it is made: changing the class later does not change the volumes it already made.
One class may carry the annotation `storageclass.kubernetes.io/is-default-class: "true"`. A claim that names no class gets that one.
This cluster has kind's class `standard` (local-path provisioner) and an empty Namespace `store`.""",
"setup": NS + r"""
mkns store
mkdir -p "$WS/lesson-1.1"
""",
"steps": [
step("Read the default class",
r"""`kubectl get storageclass` marks the default with `(default)`. Write its name, its provisioner and its binding mode, separated by spaces, to `{WS}/lesson-1.1/default.txt` - for example `fast ebs.csi.aws.com Immediate`.""",
check=r"""
want="$(j sc standard '{.metadata.name} {.provisioner} {.volumeBindingMode}')"
[ -n "$want" ] && eq "$(tr -s ' \t' ' ' < "$WS/lesson-1.1/default.txt" 2>/dev/null | tr -d '\r\n' | sed 's/ *$//')" "$want" """,
hint=r"""`kubectl get sc` shows all three columns. For exactly the values: `-o jsonpath='{.metadata.name} {.provisioner} {.volumeBindingMode}'`.""",
show=[cmd(r"""k get sc"""),
hand(r"""echo 'NAME PROVISIONER MODE' > {WS}/lesson-1.1/default.txt""",
r"""From the `(default)` line: its name, `PROVISIONER` and `VOLUMEBINDINGMODE`.""",
run=r"""kubectl get sc standard -o jsonpath='{.metadata.name} {.provisioner} {.volumeBindingMode}' > {WS}/lesson-1.1/default.txt""")]),
step("A class that keeps its volumes",
r"""Create StorageClass `keep`: the same provisioner as `standard` (`rancher.io/local-path`), binding mode `WaitForFirstConsumer`, and reclaim policy **`Retain`**, so a volume outlives its claim.
There is no `kubectl create storageclass`: write the manifest. `kubectl get sc standard -o yaml` is a good start - drop the default annotation when you copy it.""",
check=r"""
eq "$(j sc keep '{.provisioner} {.reclaimPolicy} {.volumeBindingMode}')" "rancher.io/local-path Retain WaitForFirstConsumer" \
&& [ "$(j sc keep '{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" != true ]""",
hint=r"""A StorageClass has no `spec`: `provisioner`, `reclaimPolicy` and `volumeBindingMode` sit at the top level, beside `metadata`. Its `apiVersion` is `storage.k8s.io/v1`.""",
show=[hand(r"""vim keep.yaml""",
r"""No `kubectl create` for a StorageClass: paste the example from the docs page *Storage Classes*, and make it:
```
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: keep
provisioner: rancher.io/local-path
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
```""",
run=r"""cat > keep.yaml <<'EOF'
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: keep
provisioner: rancher.io/local-path
reclaimPolicy: Retain
volumeBindingMode: WaitForFirstConsumer
EOF"""),
cmd(r"""k apply -f keep.yaml
k get sc""")]),
step("A claim that waits",
r"""In Namespace `store`, create PersistentVolumeClaim `ledger`: class `keep`, `ReadWriteOnce`, `100Mi`.
It stays `Pending`, and that is correct. `kubectl -n store describe pvc ledger` says why in its events; write the event's **reason** (one word) to `{WS}/lesson-1.1/why.txt`.""",
check=r"""
eq "$(j -n store pvc ledger '{.spec.storageClassName}')" keep && eq "$(j -n store pvc ledger '{.spec.resources.requests.storage}')" 100Mi \
&& grep -qx 'WaitForFirstConsumer' <(tr -d ' \r' < "$WS/lesson-1.1/why.txt" 2>/dev/null)""",
hint=r"""The events at the bottom of `describe` have a Reason column. A claim is namespaced; the class is not.""",
show=[hand(r"""vim ledger.yaml""",
r"""No `kubectl create` for a claim: paste the example from the docs page *Persistent Volumes*, and make it:
```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ledger
spec:
storageClassName: keep
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 100Mi
```""",
run=r"""cat > ledger.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ledger
spec:
storageClassName: keep
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 100Mi
EOF"""),
cmd(r"""k -n store apply -f ledger.yaml
k -n store describe pvc ledger | tail -4""", r"""The event's reason: `WaitForFirstConsumer`."""),
cmd(r"""echo WaitForFirstConsumer > {WS}/lesson-1.1/why.txt""")]),
step("Give it a consumer",
r"""Run Pod `bookkeeper` in `store` (image `busybox:1.36`) that mounts claim `ledger` at `/data` and runs `sh -c 'echo first > /data/entry.txt && sleep 86400'`.
As soon as the Pod is scheduled the provisioner makes a volume on its node and the claim turns `Bound`. Look at the new PersistentVolume: `kubectl get pv`. Its reclaim policy came from the class.""",
check=r"""
eq "$(j -n store pvc ledger '{.status.phase}')" Bound || exit 1
pv=$(j -n store pvc ledger '{.spec.volumeName}'); eq "$(j pv "$pv" '{.spec.persistentVolumeReclaimPolicy}')" Retain || exit 1
eq "$($K -n store exec bookkeeper -- cat /data/entry.txt 2>/dev/null)" first""",
hint=r"""`kubectl run bookkeeper --image=busybox:1.36 --dry-run=client -o yaml -- sh -c '...'` gives you the Pod; add `volumes:` (with `persistentVolumeClaim.claimName`) at Pod level and `volumeMounts:` in the container.""",
show=[cmd(r"""k -n store run bookkeeper --image=busybox:1.36 --dry-run=client -o yaml -- sh -c 'echo first > /data/entry.txt && sleep 86400' > bk.yaml"""),
hand(r"""vim bk.yaml""",
r"""Add the mount to the container, below `resources: {}`:
```
volumeMounts:
- {name: data, mountPath: /data}
```
and the volume to the Pod's `spec:`, beside `restartPolicy:`:
```
volumes:
- {name: data, persistentVolumeClaim: {claimName: ledger}}
```""",
run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "data", "mountPath": "/data"}] | .spec.volumes = [{"name": "data", "persistentVolumeClaim": {"claimName": "ledger"}}]' bk.yaml"""),
cmd(r"""k -n store apply -f bk.yaml
k -n store get pvc ledger
k get pv""", r"""Again once the Pod runs: the claim is `Bound`, and its volume has `RECLAIM POLICY` `Retain`.""")]),
step("Delete the claim, keep the volume",
r"""Delete Pod `bookkeeper` and claim `ledger`. Because of `Retain`, the volume stays, with status `Released`: its data is still on disk, and no new claim can bind it until an admin says so.
Write the PersistentVolume's name to `{WS}/lesson-1.1/released.txt`.""",
check=r"""
[ -z "$(j -n store pvc ledger '{.metadata.name}')" ] || exit 1
pv=$(tr -d ' \r\n' < "$WS/lesson-1.1/released.txt" 2>/dev/null); [ -n "$pv" ] || exit 1
eq "$(j pv "$pv" '{.status.phase} {.spec.claimRef.namespace}/{.spec.claimRef.name}')" "Released store/ledger" """,
hint=r"""Read the volume name off the claim before you delete it (`kubectl -n store get pvc ledger -o jsonpath='{.spec.volumeName}'`), or find it afterwards in `kubectl get pv`: the CLAIM column still names `store/ledger`.""",
show=[cmd(r"""k -n store get pvc ledger""", r"""`VOLUME` is the PersistentVolume's name."""),
hand(r"""echo PV > {WS}/lesson-1.1/released.txt""",
r"""`PV`: that name.""",
run=r"""kubectl -n store get pvc ledger -o jsonpath='{.spec.volumeName}' > {WS}/lesson-1.1/released.txt"""),
cmd(r"""k -n store delete po bookkeeper --now
k -n store delete pvc ledger
k get pv""", r"""The volume stays, `Released`.""")]),
step("Change the default class",
r"""Make `keep` the cluster's default class, and `standard` no longer the default. Two defaults would be ambiguous: set the annotation to `"true"` on one and `"false"` on the other.""",
check=r"""
eq "$(j sc keep '{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" true \
&& [ "$(j sc standard '{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" != true ]""",
hint=r"""`kubectl annotate sc NAME storageclass.kubernetes.io/is-default-class=true --overwrite`, or `kubectl patch` with a `metadata.annotations` map.""",
show=[cmd(r"""k annotate sc standard storageclass.kubernetes.io/is-default-class=false --overwrite
k annotate sc keep storageclass.kubernetes.io/is-default-class=true --overwrite
k get sc""")]),
],
"outro": r"""You read a class, made one that keeps its volumes, watched a claim wait for its Pod, kept a volume past its claim, and moved the default.
**For the exam**
- A StorageClass has no `spec`; `provisioner`, `reclaimPolicy`, `volumeBindingMode` and `allowVolumeExpansion` are top-level fields.
- `WaitForFirstConsumer`: a claim is `Pending` until a Pod uses it - not a fault. Look at `describe pvc` before you "fix" anything.
- The reclaim policy is copied into the volume when it is made. To keep an existing volume, patch the **PV**: `kubectl patch pv NAME -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'`.
- Growing a claim: the class needs `allowVolumeExpansion: true` and a driver that can expand, then raise `spec.resources.requests.storage` on the claim - claims only grow. The local-path provisioner can do neither that nor snapshots; a CSI driver such as the hostpath driver behind `csi-hostpath-sc` can, and lesson 1.3 grows, snapshots and restores a claim on it.
- One default class. `kubectl get sc` shows `(default)`.
Next, prove it against the clock: **drill 1.1**.""",
}
# ---- 1.2 volume types, access modes and reclaim policies -------------------------------------
L12 = {
"id": 9102, "objective": "1.2", "workers": 0,
"title": "Volume types, access modes and reclaim policies",
"intro": r"""A Pod's `volumes:` list says where data comes from; each container's `volumeMounts:` says where it appears. The types you meet on the exam:
```
emptyDir a scratch directory that lives and dies with the Pod (medium: Memory = tmpfs)
hostPath a directory of the node - tied to that node
configMap keys of a ConfigMap as files secret the same for a Secret
projected several of those, plus downwardAPI and a ServiceAccount token, in ONE directory
persistentVolumeClaim a claim, bound to a PersistentVolume
```
A PersistentVolume has **access modes** - `ReadWriteOnce` (one *node* at a time), `ReadOnlyMany`, `ReadWriteMany`, `ReadWriteOncePod` (one *Pod*) - and a **reclaim policy**, `Delete` or `Retain`. A claim binds a volume whose modes, size and class fit it.
Namespace `vols` has ConfigMap `app-config` (key `mode`) and Secret `app-secret` (key `password`).""",
"setup": NS + r"""
mkns vols
$K -n vols create configmap app-config --from-literal=mode=prod >/dev/null
$K -n vols create secret generic app-secret --from-literal=password=s3cr3t >/dev/null
mkdir -p "$WS/lesson-1.2"
""",
"steps": [
step("Two containers, one emptyDir",
r"""Create Pod `pair` in `vols` with an `emptyDir` volume `shared`, mounted at `/shared` in two `busybox:1.36` containers:
- `writer`: `sh -c 'echo hello > /shared/msg && sleep 86400'`
- `reader`: `sleep 86400`
The reader sees what the writer wrote: an emptyDir is shared by every container of its Pod.""",
check=r"""eq "$($K -n vols exec pair -c reader -- cat /shared/msg 2>/dev/null)" hello \
&& [ -n "$(j -n vols pod pair '{.spec.volumes[?(@.name=="shared")].emptyDir}')" ]""",
hint=r"""Both containers list the same `volumeMounts` entry (`name: shared`); the Pod lists the volume once, as `emptyDir: {}`.""",
show=[cmd(r"""k -n vols run pair --image=busybox:1.36 --dry-run=client -o yaml -- sh -c 'echo hello > /shared/msg && sleep 86400' > pair.yaml"""),
hand(r"""vim pair.yaml""",
r"""Name the container `writer` and give it the mount; add `reader` below it; add the volume:
```
containers:
- name: writer
image: busybox:1.36
args: [sh, -c, 'echo hello > /shared/msg && sleep 86400']
volumeMounts:
- {name: shared, mountPath: /shared}
- name: reader
image: busybox:1.36
args: [sleep, '86400']
volumeMounts:
- {name: shared, mountPath: /shared}
volumes:
- {name: shared, emptyDir: {}}
```""",
run=r"""yq -i '.spec.containers[0].name = "writer" | .spec.containers[0].volumeMounts = [{"name": "shared", "mountPath": "/shared"}]
| .spec.containers += [{"name": "reader", "image": "busybox:1.36", "args": ["sleep", "86400"], "volumeMounts": [{"name": "shared", "mountPath": "/shared"}]}]
| .spec.volumes = [{"name": "shared", "emptyDir": {}}]' pair.yaml"""),
cmd(r"""k -n vols apply -f pair.yaml
k -n vols exec pair -c reader -- cat /shared/msg""", r"""Once it runs: `hello`.""")]),
step("Scratch space in memory",
r"""Create Pod `scratch` in `vols` (`busybox:1.36`, `sleep 86400`) with an emptyDir at `/scratch` that is held in **memory**, at most `64Mi`.
Inside the Pod, `/proc/mounts` shows the difference: `/scratch` is a `tmpfs`. What is written there counts against the container's memory.""",
check=r"""
eq "$(j -n vols pod scratch '{.spec.volumes[0].emptyDir.medium}')" Memory && eq "$(j -n vols pod scratch '{.spec.volumes[0].emptyDir.sizeLimit}')" 64Mi \
&& $K -n vols exec scratch -- grep -q ' /scratch tmpfs ' /proc/mounts 2>/dev/null""",
hint=r"""`emptyDir: {medium: Memory, sizeLimit: 64Mi}`.""",
show=[cmd(r"""k -n vols run scratch --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > scratch.yaml"""),
hand(r"""vim scratch.yaml""",
r"""Add the mount to the container, below `resources: {}`:
```
volumeMounts:
- {name: tmp, mountPath: /scratch}
```
and the volume to the Pod's `spec:`, beside `restartPolicy:`:
```
volumes:
- {name: tmp, emptyDir: {medium: Memory, sizeLimit: 64Mi}}
```""",
run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "tmp", "mountPath": "/scratch"}] | .spec.volumes = [{"name": "tmp", "emptyDir": {"medium": "Memory", "sizeLimit": "64Mi"}}]' scratch.yaml"""),
cmd(r"""k -n vols apply -f scratch.yaml
k -n vols exec scratch -- grep /scratch /proc/mounts""", r"""Once it runs: a `tmpfs`.""")]),
step("One directory from three sources",
r"""Create Pod `bundle` in `vols` (`busybox:1.36`, `sleep 86400`) with **one** `projected` volume mounted at `/etc/bundle`, made of:
- ConfigMap `app-config` (gives the file `mode`),
- Secret `app-secret` (gives `password`),
- the downward API: the Pod's own name as the file `podname`.
Separate volumes would need three mount points; a projected volume puts them side by side.""",
check=r"""
x() { $K -n vols exec bundle -- cat "/etc/bundle/$1" 2>/dev/null; }
[ -n "$(j -n vols pod bundle '{.spec.volumes[0].projected}')" ] && eq "$(x mode)" prod && eq "$(x password)" s3cr3t && eq "$(x podname)" bundle""",
hint=r"""`projected: {sources: [...]}` - each source is `configMap: {name: ...}`, `secret: {name: ...}` or `downwardAPI: {items: [{path: podname, fieldRef: {fieldPath: metadata.name}}]}`. `kubectl explain pod.spec.volumes.projected.sources` lists them.""",
show=[cmd(r"""k -n vols run bundle --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > bundle.yaml"""),
hand(r"""vim bundle.yaml""",
r"""Add the mount to the container, below `resources: {}`:
```
volumeMounts:
- {name: bundle, mountPath: /etc/bundle, readOnly: true}
```
and the volume to the Pod's `spec:`, beside `restartPolicy:` (the docs page *Projected Volumes* has an example):
```
volumes:
- name: bundle
projected:
sources:
- configMap: {name: app-config}
- secret: {name: app-secret}
- downwardAPI:
items:
- path: podname
fieldRef: {fieldPath: metadata.name}
```""",
run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "bundle", "mountPath": "/etc/bundle", "readOnly": true}]
| .spec.volumes = [{"name": "bundle", "projected": {"sources": [{"configMap": {"name": "app-config"}}, {"secret": {"name": "app-secret"}}, {"downwardAPI": {"items": [{"path": "podname", "fieldRef": {"fieldPath": "metadata.name"}}]}}]}}]' bundle.yaml"""),
cmd(r"""k -n vols apply -f bundle.yaml
k -n vols exec bundle -- ls /etc/bundle""", r"""Once it runs: `mode`, `password` and `podname`.""")]),
step("A volume for exactly one Pod",
r"""Create PersistentVolume `archive`: `hostPath` `/srv/archive` (type `DirectoryOrCreate`), `200Mi`, access mode **`ReadWriteOncePod`**, reclaim policy `Retain`, and `storageClassName: ""`.
Then claim it: PVC `archive-claim` in `vols`, `ReadWriteOncePod`, `200Mi`, `storageClassName: ""`.
The empty class matters on both sides. A claim with no class at all gets the default class, and its provisioner would make a fresh volume instead of binding yours.""",
check=r"""
eq "$(j pv archive '{.spec.accessModes[0]} {.spec.persistentVolumeReclaimPolicy} {.spec.hostPath.path}')" "ReadWriteOncePod Retain /srv/archive" \
&& eq "$(j -n vols pvc archive-claim '{.status.phase} {.spec.volumeName}')" "Bound archive" """,
hint=r"""`storageClassName: ""` is spelled out, with the quotes, under `spec` of both objects. A PersistentVolume is cluster-wide: no namespace.""",
show=[hand(r"""vim archive.yaml""",
r"""No `kubectl create` for these: paste the PersistentVolume and the claim from the docs page *Configure a Pod to Use a PersistentVolume for Storage*, and make them:
```
apiVersion: v1
kind: PersistentVolume
metadata:
name: archive
spec:
capacity: {storage: 200Mi}
accessModes: [ReadWriteOncePod]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
hostPath: {path: /srv/archive, type: DirectoryOrCreate}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: archive-claim
namespace: vols
spec:
storageClassName: ""
accessModes: [ReadWriteOncePod]
resources:
requests: {storage: 200Mi}
```""",
run=r"""cat > archive.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolume
metadata:
name: archive
spec:
capacity: {storage: 200Mi}
accessModes: [ReadWriteOncePod]
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
hostPath: {path: /srv/archive, type: DirectoryOrCreate}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: archive-claim
namespace: vols
spec:
storageClassName: ""
accessModes: [ReadWriteOncePod]
resources:
requests: {storage: 200Mi}
EOF"""),
cmd(r"""k apply -f archive.yaml
k get pv archive
k -n vols get pvc archive-claim""", r"""Both `Bound`, to each other.""")]),
step("Hand a Released volume to a new claim",
r"""Delete claim `archive-claim`. The volume becomes `Released`: it still remembers its old claim in `spec.claimRef`, and nothing binds it while it does.
Make it `Available` again by removing `spec.claimRef` from the PV, then create claim `archive-claim-2` in `vols` (same modes, size and empty class). It binds `archive`, and the data on it is still there.""",
check=r"""
[ -z "$(j -n vols pvc archive-claim '{.metadata.name}')" ] && eq "$(j -n vols pvc archive-claim-2 '{.status.phase} {.spec.volumeName}')" "Bound archive" """,
hint=r"""`kubectl patch pv archive --type json -p '[{"op":"remove","path":"/spec/claimRef"}]'` - or `kubectl edit pv archive` and delete the `claimRef:` block.""",
show=[cmd(r"""k -n vols delete pvc archive-claim
k get pv archive""", r"""`Released`."""),
hand(r"""k edit pv archive""",
r"""Delete the whole `claimRef:` block, then `:wq`. The volume is `Available` again.""",
run=r"""kubectl patch pv archive --type json -p '[{"op":"remove","path":"/spec/claimRef"}]' """),
hand(r"""vim claim2.yaml""",
r"""The claim of `archive.yaml`, named `archive-claim-2`:
```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: archive-claim-2
namespace: vols
spec:
storageClassName: ""
accessModes: [ReadWriteOncePod]
resources:
requests: {storage: 200Mi}
```""",
run=r"""cat > claim2.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: archive-claim-2
namespace: vols
spec:
storageClassName: ""
accessModes: [ReadWriteOncePod]
resources:
requests: {storage: 200Mi}
EOF"""),
cmd(r"""k apply -f claim2.yaml
k get pv archive""")]),
],
"outro": r"""You shared an emptyDir between containers, put one in memory, combined three sources in one projected volume, and took a `Retain` volume through `Bound`, `Released` and back.
**For the exam**
- `ReadWriteOnce` is one **node**, not one Pod; `ReadWriteOncePod` is one Pod. A claim's modes must be a subset of the volume's.
- `Retain` keeps the data and leaves the volume `Released`; to reuse it, remove `spec.claimRef` (or delete and re-create the PV). `Delete` removes the volume with its claim. `Recycle` is deprecated.
- `storageClassName: ""` binds a pre-made volume; leaving the field out means "the default class".
- configMap and secret volumes follow changes to their object after a short delay - but not when mounted with `subPath`, and environment variables never do.
- `kubectl explain pod.spec.volumes` lists every volume type; the docs page "Volumes" has an example of each.
Next, prove it against the clock: **drill 1.2**.""",
}
# ---- 1.3 PersistentVolumes and PersistentVolumeClaims ---------------------------------------
_PVS = r"""
$K apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-small
labels: {tier: bronze}
spec:
capacity: {storage: 100Mi}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: {path: /srv/pv-small, type: DirectoryOrCreate}
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-large
labels: {tier: gold}
spec:
capacity: {storage: 1Gi}
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath: {path: /srv/pv-large, type: DirectoryOrCreate}
EOF
"""
L13 = {
"id": 9103, "objective": "1.3", "workers": 0,
"title": "Claims: binding the right volume, protection, and claims that come and go",
"intro": r"""A claim binds **one** volume, and keeps it until the claim is deleted. Among the volumes that fit - access modes, class, at least the requested size - Kubernetes picks the smallest. You can steer it:
```
selector: matchLabels on the volume's labels "one of the gold volumes"
volumeName: the volume's name "exactly this volume"
```
Claims are also protected: one that a Pod still uses is not removed until the Pod is gone. And a StatefulSet makes claims of its own, one per replica, from `volumeClaimTemplates` - its `persistentVolumeClaimRetentionPolicy` says whether they go when replicas go.
Some things only a **CSI driver** can do for a claim: grow it, snapshot it, make a new claim from a snapshot or from another claim. This cluster has the CSI hostpath driver and its class `csi-hostpath-sc` (expandable), as the exam's clusters do, besides kind's `standard`.
Two volumes are ready, both of class `manual`: `pv-small` (100Mi, label `tier=bronze`) and `pv-large` (1Gi, `tier=gold`). Namespace `claims` has claim `growing` (`csi-hostpath-sc`, 10Mi), used by Pod `grower`.""",
"setup": NS + _PVS + r"""
ensure_csi_hostpath || { echo "could not install the CSI hostpath driver" >&2; exit 1; }
mkns claims
mkdir -p "$WS/lesson-1.3"
$K -n claims apply -f - >/dev/null <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: {name: growing}
spec: {storageClassName: csi-hostpath-sc, accessModes: [ReadWriteOnce], resources: {requests: {storage: 10Mi}}}
---
apiVersion: v1
kind: Pod
metadata: {name: grower}
spec:
containers:
- {name: grower, image: busybox:1.36, command: [sh, -c, 'echo kept > /data/note.txt; sync; sleep 86400'], volumeMounts: [{name: d, mountPath: /data}]}
volumes: [{name: d, persistentVolumeClaim: {claimName: growing}}]
EOF
$K -n claims wait --for=condition=Ready pod/grower --timeout=180s >/dev/null
""",
"steps": [
step("Bind by label",
r"""Create claim `gold-claim` in `claims`: class `manual`, `ReadWriteOnce`, `100Mi` - and a **selector** for `tier=gold`.
Without the selector it would bind `pv-small`, the smallest volume that fits. With it, it binds `pv-large`.""",
check=r"""eq "$(j -n claims pvc gold-claim '{.status.phase} {.spec.volumeName}')" "Bound pv-large" """,
hint=r"""`spec.selector.matchLabels: {tier: gold}` on the claim.""",
show=[hand(r"""vim gold.yaml""",
r"""A claim with a `selector` (the docs page *Persistent Volumes* has one):
```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gold-claim
spec:
storageClassName: manual
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 100Mi}
selector:
matchLabels: {tier: gold}
```""",
run=r"""cat > gold.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gold-claim
spec:
storageClassName: manual
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 100Mi}
selector:
matchLabels: {tier: gold}
EOF"""),
cmd(r"""k -n claims apply -f gold.yaml
k get pv""", r"""`gold-claim` bound to `pv-large`.""")]),
step("Bind by name, and use it",
r"""Create claim `pinned` in `claims` that binds `pv-small` **by name** (class `manual`, `ReadWriteOnce`, `100Mi`), and Pod `user` (`busybox:1.36`, `sleep 86400`) that mounts it at `/data`.""",
check=r"""eq "$(j -n claims pvc pinned '{.status.phase} {.spec.volumeName}')" "Bound pv-small" && eq "$(j -n claims pod user '{.status.phase}')" Running \
&& eq "$(j -n claims pod user '{.spec.volumes[0].persistentVolumeClaim.claimName}')" pinned""",
hint=r"""`spec.volumeName: pv-small` on the claim. The volume's class and modes must still match the claim's.""",
show=[hand(r"""vim pinned.yaml""",
r"""A claim that names its volume:
```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pinned
spec:
storageClassName: manual
volumeName: pv-small
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 100Mi}
```""",
run=r"""cat > pinned.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pinned
spec:
storageClassName: manual
volumeName: pv-small
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 100Mi}
EOF"""),
cmd(r"""k -n claims apply -f pinned.yaml
k -n claims run user --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400 > user.yaml"""),
hand(r"""vim user.yaml""",
r"""Add the mount to the container, below `resources: {}`:
```
volumeMounts:
- {name: data, mountPath: /data}
```
and the volume to the Pod's `spec:`, beside `restartPolicy:`:
```
volumes:
- {name: data, persistentVolumeClaim: {claimName: pinned}}
```""",
run=r"""yq -i '.spec.containers[0].volumeMounts = [{"name": "data", "mountPath": "/data"}] | .spec.volumes = [{"name": "data", "persistentVolumeClaim": {"claimName": "pinned"}}]' user.yaml"""),
cmd(r"""k -n claims apply -f user.yaml
k -n claims get pvc,po""")]),
step("A claim in use does not go",
r"""Delete claim `pinned` without waiting (`--wait=false`). It does not disappear: `kubectl -n claims get pvc` shows it `Terminating`, held by a **finalizer**.
Write that finalizer's name to `{WS}/lesson-1.3/finalizer.txt`.""",
check=r"""
[ -n "$(j -n claims pvc pinned '{.metadata.deletionTimestamp}')" ] \
&& eq "$(tr -d ' \r\n' < "$WS/lesson-1.3/finalizer.txt" 2>/dev/null)" kubernetes.io/pvc-protection""",
hint=r"""`kubectl -n claims get pvc pinned -o jsonpath='{.metadata.finalizers}'`. Without `--wait=false`, `kubectl delete` would sit there until the claim is really gone.""",
show=[cmd(r"""k -n claims delete pvc pinned --wait=false
k -n claims get pvc pinned
k -n claims get pvc pinned -o yaml | grep -A1 finalizers"""),
hand(r"""echo FINALIZER > {WS}/lesson-1.3/finalizer.txt""",
r"""`FINALIZER`: the name under `finalizers:`.""",
run=r"""kubectl -n claims get pvc pinned -o jsonpath='{.metadata.finalizers[0]}' > {WS}/lesson-1.3/finalizer.txt""")]),
step("Let it go",
r"""Delete Pod `user`. With no Pod using it any more, the protection lifts and claim `pinned` is removed. `pv-small` (reclaim policy `Retain`) is now `Released`.""",
check=r"""[ -z "$(j -n claims pod user '{.metadata.name}')" ] && [ -z "$(j -n claims pvc pinned '{.metadata.name}')" ] && eq "$(j pv pv-small '{.status.phase}')" Released""",
hint=r"""`kubectl -n claims delete pod user`, then `kubectl -n claims get pvc` and `kubectl get pv`.""",
show=[cmd(r"""k -n claims delete po user --now
k -n claims get pvc
k get pv pv-small""", r"""The claim is gone; `pv-small` is `Released`.""")]),
step("Claims made by a StatefulSet",
r"""Create a StatefulSet `db` in `claims` with 2 replicas (`busybox:1.36`, `sleep 86400`) and its headless Service `db`. Give it a `volumeClaimTemplates` entry `data` (`ReadWriteOnce`, `50Mi`, class `standard`) mounted at `/var/lib/db`, and a `persistentVolumeClaimRetentionPolicy` that **deletes** a replica's claim when the StatefulSet is scaled down and **retains** the claims when the StatefulSet is deleted.
Each replica gets a claim of its own: `data-db-0`, `data-db-1`.""",
check=r"""
eq "$(j -n claims sts db '{.spec.persistentVolumeClaimRetentionPolicy.whenScaled} {.spec.persistentVolumeClaimRetentionPolicy.whenDeleted}')" "Delete Retain" || exit 1
eq "$(j -n claims sts db '{.status.readyReplicas}')" 2 && eq "$(j -n claims pvc data-db-0 '{.status.phase}')" Bound && eq "$(j -n claims pvc data-db-1 '{.status.phase}')" Bound""",
hint=r"""`kubectl create deploy db --image=busybox:1.36 --dry-run=client -o yaml -- sleep 86400` is a start: change the kind, add `serviceName: db`, drop `strategy`, add `volumeClaimTemplates` beside `template`. The Service: `clusterIP: None`. `kubectl explain sts.spec.persistentVolumeClaimRetentionPolicy`.""",
show=[hand(r"""vim db.yaml""",
r"""No `kubectl create` for a StatefulSet: paste the example from the docs page *StatefulSets* (it comes with its headless Service), and make it:
```
apiVersion: v1
kind: Service
metadata:
name: db
spec:
clusterIP: None
selector: {app: db}
ports:
- port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: db
spec:
serviceName: db
replicas: 2
selector:
matchLabels: {app: db}
persistentVolumeClaimRetentionPolicy:
whenScaled: Delete
whenDeleted: Retain
template:
metadata:
labels: {app: db}
spec:
containers:
- name: db
image: busybox:1.36
command: [sleep, '86400']
volumeMounts:
- {name: data, mountPath: /var/lib/db}
volumeClaimTemplates:
- metadata:
name: data
spec:
storageClassName: standard
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 50Mi}
```""",
run=r"""cat > db.yaml <<'EOF'
apiVersion: v1
kind: Service
metadata:
name: db
spec:
clusterIP: None
selector: {app: db}
ports:
- port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: db
spec:
serviceName: db
replicas: 2
selector:
matchLabels: {app: db}
persistentVolumeClaimRetentionPolicy:
whenScaled: Delete
whenDeleted: Retain
template:
metadata:
labels: {app: db}
spec:
containers:
- name: db
image: busybox:1.36
command: [sleep, '86400']
volumeMounts:
- {name: data, mountPath: /var/lib/db}
volumeClaimTemplates:
- metadata:
name: data
spec:
storageClassName: standard
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 50Mi}
EOF"""),
cmd(r"""k -n claims apply -f db.yaml
k -n claims rollout status sts db
k -n claims get pvc""", r"""`data-db-0` and `data-db-1`.""")]),
step("Scale down, and the claim goes with the replica",
r"""Scale `db` to 1 replica. Pod `db-1` goes, and because `whenScaled` is `Delete`, so does `data-db-1`. `data-db-0` stays.
The default for both fields is `Retain`: without the policy, `data-db-1` would wait for the replica to come back.""",
check=r"""
eq "$(j -n claims sts db '{.spec.replicas}')" 1 && eq "$(j -n claims pvc data-db-0 '{.status.phase}')" Bound || exit 1
for i in $(seq 1 10); do [ -z "$(j -n claims pvc data-db-1 '{.metadata.name}')" ] && exit 0; sleep 3; done; exit 1""",
hint=r"""`kubectl -n claims scale sts db --replicas=1`, then `kubectl -n claims get pvc -w`.""",
show=[cmd(r"""k -n claims scale sts db --replicas=1
k -n claims rollout status sts db
k -n claims get pvc""", r"""Again in a moment: `data-db-1` is gone, `data-db-0` stays.""")]),
step("Grow a claim in use",
r"""Claim `growing` is full. Its class allows expansion (`kubectl get sc csi-hostpath-sc`: `ALLOWVOLUMEEXPANSION true`), so make it **50Mi** - while Pod `grower` keeps running. Raise the request; the driver does the rest, and `status.capacity` follows.""",
check=r"""eq "$(j -n claims pvc growing '{.spec.resources.requests.storage} {.status.capacity.storage}')" "50Mi 50Mi" && eq "$(j -n claims pod grower '{.status.phase} {.status.containerStatuses[0].restartCount}')" "Running 0" """,
hint=r"""`kubectl -n claims edit pvc growing`, or `kubectl patch pvc growing -p '{"spec":{"resources":{"requests":{"storage":"50Mi"}}}}'`. A claim never shrinks; with `standard` (local-path) the same patch is refused.""",
show=[cmd(r"""k get sc csi-hostpath-sc"""),
hand(r"""k -n claims edit pvc growing""",
r"""Make `storage:` under `spec.resources.requests` read `50Mi`, then `:wq`.""",
run=r"""kubectl -n claims patch pvc growing -p '{"spec":{"resources":{"requests":{"storage":"50Mi"}}}}' """),
cmd(r"""k -n claims get pvc growing""", r"""Again until `CAPACITY` shows `50Mi`.""")]),
step("Snapshot it, and restore the snapshot",
r"""Take VolumeSnapshot `growing-snap` of claim `growing` (snapshot class `csi-hostpath-snapclass`), wait until it is ready to use, then create claim `restored` (`csi-hostpath-sc`, 50Mi) **from** the snapshot: its `dataSource` names the VolumeSnapshot. The new volume holds what `growing` held when the snapshot was taken.""",
check=r"""
eq "$(j -n claims volumesnapshot growing-snap '{.spec.source.persistentVolumeClaimName} {.status.readyToUse}')" "growing true" && eq "$(j -n claims pvc restored '{.spec.dataSource.kind}/{.spec.dataSource.name} {.status.phase}')" "VolumeSnapshot/growing-snap Bound" """,
hint=r"""`apiVersion: snapshot.storage.k8s.io/v1`, `kind: VolumeSnapshot`, `spec: {volumeSnapshotClassName: ..., source: {persistentVolumeClaimName: growing}}`. The claim: `dataSource: {apiGroup: snapshot.storage.k8s.io, kind: VolumeSnapshot, name: growing-snap}`. A clone is the same with `dataSource: {kind: PersistentVolumeClaim, name: growing}`.""",
show=[hand(r"""vim snap.yaml""",
r"""Paste a VolumeSnapshot from the docs page *Volume Snapshots*, and make it:
```
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: growing-snap
spec:
volumeSnapshotClassName: csi-hostpath-snapclass
source:
persistentVolumeClaimName: growing
```""",
run=r"""cat > snap.yaml <<'EOF'
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: growing-snap
spec:
volumeSnapshotClassName: csi-hostpath-snapclass
source:
persistentVolumeClaimName: growing
EOF"""),
cmd(r"""k -n claims apply -f snap.yaml"""),
hand(r"""k -n claims get volumesnapshot""",
r"""Again until `READYTOUSE` is `true`.""",
run=r"""kubectl -n claims wait --for=jsonpath='{.status.readyToUse}'=true volumesnapshot/growing-snap --timeout=120s"""),
hand(r"""vim restored.yaml""",
r"""A claim whose `dataSource` is the snapshot (the same docs page, *Provisioning Volumes from Snapshots*):
```
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: restored
spec:
storageClassName: csi-hostpath-sc
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 50Mi}
dataSource:
apiGroup: snapshot.storage.k8s.io
kind: VolumeSnapshot
name: growing-snap
```""",
run=r"""cat > restored.yaml <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: restored
spec:
storageClassName: csi-hostpath-sc
accessModes: [ReadWriteOnce]
resources:
requests: {storage: 50Mi}
dataSource:
apiGroup: snapshot.storage.k8s.io
kind: VolumeSnapshot
name: growing-snap
EOF"""),
cmd(r"""k -n claims apply -f restored.yaml
k -n claims get volumesnapshot,pvc""")]),
],
"outro": r"""You steered binding with a selector and with a volume's name, watched claim protection hold a claim until its Pod went, let a StatefulSet's claims follow its replicas, and grew, snapshotted and restored a claim on a CSI class.
**For the exam**
- A claim binds the smallest volume that fits, unless `selector` or `volumeName` say otherwise. The class must match on both sides.
- A `Pending` claim: `describe pvc` - no volume fits (size, modes, class, selector), the class has no provisioner, or it waits for its first consumer.
- `Terminating` claim: some Pod still uses it (`kubernetes.io/pvc-protection`). Find the Pod, not the finalizer.
- StatefulSet claims are named `TEMPLATE-STATEFULSET-ORDINAL` and kept by default; `persistentVolumeClaimRetentionPolicy` (`whenScaled`, `whenDeleted`) can delete them.
- A Pod's `ephemeral.volumeClaimTemplate` makes a claim that lives and dies with the Pod - a generic ephemeral volume, named `POD-VOLUME`.
- Expansion, snapshots and clones need a CSI driver that does them: `kubectl get sc` (ALLOWVOLUMEEXPANSION), `kubectl get volumesnapshotclass`, `kubectl get csidrivers`.
Next, prove it against the clock: **drill 1.3**.""",
}
LESSONS = [L11, L12, L13]