kubestronaut-sim/ckad/bank_net.py
vumas3r f3d0e536b6 Exam simulator for CKA, CKAD and CKS with per-task hosts and a remote desktop
kubestronaut-sim runs each exam task on a host of its own - a kind (kubeadm)
cluster in rootless podman, or a QEMU/KVM Ubuntu VM for the CKS tasks that
need their own kernel (Falco, AppArmor) - and puts a browser desktop in front
of them, the way killer.sh and sailor.sh do.

- ksim, sim/: the engine (sessions, drills, study mode, grading, coverage,
  audit), host and VM lifecycle, the desktop
- images/: exam host images (common, CKS variant with its tools, VM guest)
- cka/ (131 tasks), ckad/ (105), cks/ (110): task banks, curricula with
  study notes, reference solutions and scripted solutions

Every domain of every track has at least 17 pool tasks. `./ksim audit` proves
a task three ways: it seeds cleanly on its own host, scores nothing before
anyone has worked on it, and reaches full marks when its scripted solution is
run on the host as `candidate`. All 346 tasks pass (CKA audited 2026-10-13,
CKAD and CKS 2026-10-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZPd3GGWyxTgAkL6a4gAJz
2026-10-13 16:29:33 -06:00

685 lines
32 KiB
Python

"""CKAD tasks 65-76: Services and Networking - the pool tasks that take the domain from 5 to 17.
5.1 NetworkPolicies 65 default deny, then one allow 66 namespace AND pod selector
67 a policy that lets nothing through 68 same Namespace only
69 get through policies you may not change
5.2 Services 70 target port by name, sticky sessions 71 ExternalName
72 endpoints that never become ready 73 a Service without a selector
74 narrow a selector to one version
5.3 Ingress 75 two hosts and a default backend 76 Exact and Prefix paths, rewrite
Every NetworkPolicy and Service task is tested with real traffic (kindnet enforces NetworkPolicy).
A check that something is blocked only counts while the task's own work is in place and the allowed
path demonstrably works - a connection that fails proves nothing by itself, and a task must score
nothing before anyone has worked on it. No Ingress controller runs here: 75 and 76 are graded on
the object. Each task has a scripted solution in solve.py, which `./ksim audit` runs on the host.
"""
from sim.bank import C, NS, ns_pick, name_pick
# Traffic probes for the checks. ok NS POD URL one try; okr NS POD URL a few, for a path
# that should work (a policy or an endpoint takes a moment to reach the node).
NET = r"""
ok() { $K -n "$1" exec "$2" -- wget -T 3 -qO- "$3" >/dev/null 2>&1; }
okr() { for _ in 1 2 3 4; do ok "$@" && return 0; sleep 2; done; return 1; }
# blocked NS POD URL: the request fails - at once, or once a policy applied a moment ago has landed
blocked() { for _ in 1 2 3; do ok "$@" || return 0; sleep 2; done; return 1; }
"""
# Scenario building blocks for the setups. srv NS NAME [LABELS] an nginx Pod with a Service of
# its name on port 80; cli NS NAME [LABELS] a busybox Pod to send requests from.
PODS = NS + r"""
srv() {
$K -n "$1" run "$2" --image=nginx:1.27 --port=80 --labels="${3:-app=$2}" >/dev/null
$K -n "$1" expose pod "$2" --port=80 >/dev/null
}
cli() { $K -n "$1" run "$2" --image=busybox:1.36 --labels="${3:-app=$2}" --command -- sleep 86400 >/dev/null; }
ready() { $K -n "$1" wait --for=condition=Ready pod --all --timeout=240s >/dev/null; }
"""
# An Ingress as JSON on stdin of a python one-liner, for the object checks.
ING = r"""
ing() { $K -n "$1" get ingress "$2" -o json 2>/dev/null | python3 -c "$3"; }
"""
QUESTIONS = [
# ============================================================ 5.1 NetworkPolicy ====
{
"id": 65, "domain": "NET", "title": "Default deny, then one way in",
"params": lambda r: {"ns": ns_pick(r, "shop")},
"task": """
Namespace `%{ns}` runs Pods `web`, `batch`, `api` and `db` (labels `app=<name>`), and today every
one of them accepts connections from anywhere.
1. Create NetworkPolicy `default-deny-ingress` that blocks **all incoming** traffic to **every**
Pod of the Namespace.
2. Create NetworkPolicy `api-from-web` that lets Pods labelled `app=api` accept TCP port 80 from
Pods labelled `app=web`, and from nothing else.
Do not restrict outgoing traffic. kindnet enforces NetworkPolicy here, so both are tested with
real requests.
""",
"setup": PODS + r"""
mkns %{ns}
srv %{ns} api; srv %{ns} db; cli %{ns} web; cli %{ns} batch
ready %{ns}
""",
"checks": [
C("default-deny-ingress selects every Pod, for Ingress only, and allows nothing", 1, r"""
eq "$(j -n %{ns} netpol default-deny-ingress '{.spec.podSelector}')" '{}' \
&& eq "$(j -n %{ns} netpol default-deny-ingress '{.spec.policyTypes}')" '["Ingress"]' \
&& [ -z "$(j -n %{ns} netpol default-deny-ingress '{.spec.ingress[*]}')" ]"""),
C("web reaches api on port 80", 2, NET + r"""
[ -n "$(j -n %{ns} netpol api-from-web '{.metadata.name}')" ] && okr %{ns} web http://api"""),
C("batch can no longer reach api", 2, NET + r"""
[ -n "$(j -n %{ns} netpol api-from-web '{.metadata.name}')" ] && okr %{ns} web http://api && blocked %{ns} batch http://api"""),
C("nothing reaches db any more", 1, NET + r"""
[ -n "$(j -n %{ns} netpol default-deny-ingress '{.metadata.name}')" ] && okr %{ns} web http://api && blocked %{ns} web http://db"""),
C("no policy of the Namespace restricts egress", 1, r"""
[ -n "$(j -n %{ns} netpol '{.items[*].metadata.name}')" ] && ! j -n %{ns} netpol '{.items[*].spec.policyTypes}' | grep -q Egress"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny-ingress, namespace: %{ns}}
spec:
podSelector: {} # every Pod of the Namespace
policyTypes: [Ingress] # and no ingress rules: nothing is allowed in
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: api-from-web, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: api}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {app: web}}}]
ports: [{protocol: TCP, port: 80}]
EOF
k -n %{ns} exec web -- wget -T 3 -qO- http://api | head -3 # works
k -n %{ns} exec batch -- wget -T 3 -qO- http://api # times out
k -n %{ns} exec web -- wget -T 3 -qO- http://db # times out: only the deny selects db
# Policies only allow and they add up: the deny is "selected, nothing allowed", and the second
# policy adds one allowance for api. There is no order between them and no deny rule.
""",
},
{
"id": 66, "domain": "NET", "title": "The scraper from the monitoring Namespace, and only that",
"params": lambda r: {"ns": ns_pick(r, "ledger")},
"task": """
Pod `metrics` (label `app=metrics`) in Namespace `%{ns}` serves on TCP port 80.
Create NetworkPolicy `metrics-scrape` in `%{ns}` so that `app=metrics` accepts port 80 only from
Pods that are labelled `role=scraper` **and** run in a Namespace labelled `purpose=monitoring`.
Both conditions must hold for the same Pod. Leave outgoing traffic alone.
For testing: Namespace `%{ns}-mon` carries that label and runs Pods `scraper` (`role=scraper`) and
`shell` (`role=debug`); Namespace `%{ns}-dev` does not carry it and runs its own Pod `scraper`
(`role=scraper`).
""",
"setup": PODS + r"""
mkns %{ns}; mkns %{ns}-mon; mkns %{ns}-dev
$K label ns %{ns}-mon purpose=monitoring --overwrite >/dev/null
srv %{ns} metrics
cli %{ns}-mon scraper role=scraper; cli %{ns}-mon shell role=debug; cli %{ns}-dev scraper role=scraper
ready %{ns}; ready %{ns}-mon; ready %{ns}-dev
""",
"checks": [
C("metrics-scrape selects app=metrics, for Ingress only", 1, r"""
eq "$(j -n %{ns} netpol metrics-scrape '{.spec.podSelector.matchLabels.app}')" metrics && eq "$(j -n %{ns} netpol metrics-scrape '{.spec.policyTypes}')" '["Ingress"]'"""),
C("scraper in %{ns}-mon reaches metrics", 3, NET + r"""
[ -n "$(j -n %{ns} netpol metrics-scrape '{.metadata.name}')" ] && okr %{ns}-mon scraper http://metrics.%{ns}"""),
C("shell in %{ns}-mon (right Namespace, wrong label) is blocked", 2, NET + r"""
[ -n "$(j -n %{ns} netpol metrics-scrape '{.metadata.name}')" ] && okr %{ns}-mon scraper http://metrics.%{ns} && blocked %{ns}-mon shell http://metrics.%{ns}"""),
C("scraper in %{ns}-dev (right label, wrong Namespace) is blocked", 2, NET + r"""
[ -n "$(j -n %{ns} netpol metrics-scrape '{.metadata.name}')" ] && okr %{ns}-mon scraper http://metrics.%{ns} && blocked %{ns}-dev scraper http://metrics.%{ns}"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: metrics-scrape, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: metrics}}
policyTypes: [Ingress]
ingress:
- from:
- namespaceSelector: {matchLabels: {purpose: monitoring}}
podSelector: {matchLabels: {role: scraper}} # same list item: AND
ports: [{protocol: TCP, port: 80}]
EOF
k -n %{ns}-mon exec scraper -- wget -T 3 -qO- http://metrics.%{ns} | head -3 # works
k -n %{ns}-mon exec shell -- wget -T 3 -qO- http://metrics.%{ns} # times out
k -n %{ns}-dev exec scraper -- wget -T 3 -qO- http://metrics.%{ns} # times out
# One dash, two selectors = both must match. Put a dash in front of podSelector as well and it
# becomes two peers: "any Pod of a monitoring Namespace" OR "role=scraper Pods of this Namespace" -
# which lets `shell` in. `k describe netpol` prints the peers one per line; read it back.
""",
},
{
"id": 67, "domain": "NET", "title": "A policy that lets nothing through",
"params": lambda r: {"ns": ns_pick(r, "queue")},
"task": """
In Namespace `%{ns}`, Pod `worker` must be able to read `http://cache`, and no other Pod may reach
`cache`. NetworkPolicy `cache-allow` was written for exactly that, but since it was applied
`worker` gets no answer either.
Find what is wrong with `cache-allow` and correct it. Fix **that policy**: do not delete it, and
do not create another one.
""",
"setup": PODS + r"""
mkns %{ns}
srv %{ns} cache; cli %{ns} worker; cli %{ns} cron
$K apply -f - >/dev/null <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: cache-allow, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: cache}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {role: worker}}}]
ports: [{protocol: TCP, port: 8080}]
EOF
j -n %{ns} netpol cache-allow '{.metadata.uid}' > "$PRIV/q67.uid"
ready %{ns}
""",
"checks": [
C("worker reaches cache", 3, NET + r"""okr %{ns} worker http://cache"""),
C("cron is still kept out", 2, NET + r"""okr %{ns} worker http://cache && blocked %{ns} cron http://cache"""),
C("cache-allow is the same object, and the only policy", 2, NET + r"""
okr %{ns} worker http://cache || exit 1
eq "$(j -n %{ns} netpol cache-allow '{.metadata.uid}')" "$(cat "$PRIV/q67.uid")" && eq "$(j -n %{ns} netpol '{.items[*].metadata.name}')" cache-allow"""),
],
"solution": """
k -n %{ns} describe netpol cache-allow # From: PodSelector role=worker To Port: 8080/TCP
k -n %{ns} get pod --show-labels # the Pod says app=worker, not role=worker
k -n %{ns} get pod cache -o jsonpath='{.spec.containers[0].ports}' # and cache listens on 80
k -n %{ns} edit netpol cache-allow
# from: podSelector: matchLabels: role: worker -> app: worker
# ports: port: 8080 -> 80
k -n %{ns} exec worker -- wget -T 3 -qO- http://cache | head -3
k -n %{ns} exec cron -- wget -T 3 -qO- http://cache # still times out
# Two faults, one symptom. A policy port is the port of the Pod (the container port), never the
# Service port, and a peer selector that matches no Pod is not an error - it just allows nobody.
""",
},
{
"id": 68, "domain": "NET", "title": "Same Namespace only",
"params": lambda r: {"ns": ns_pick(r, "tenant")},
"task": """
Namespace `%{ns}` belongs to one team. Its Pods must accept connections from **any Pod of the same
Namespace**, and from **no other Namespace**.
Create one NetworkPolicy `same-namespace` in `%{ns}` for that. It must cover every Pod the
Namespace has now or gets later, and leave outgoing traffic alone.
For testing: `front` and `back` run in `%{ns}` (`back` serves on port 80), and `visitor` runs in
Namespace `%{ns}-guest`.
""",
"setup": PODS + r"""
mkns %{ns}; mkns %{ns}-guest
srv %{ns} back; cli %{ns} front; cli %{ns}-guest visitor
ready %{ns}; ready %{ns}-guest
""",
"checks": [
C("same-namespace selects every Pod, for Ingress only", 2, r"""
eq "$(j -n %{ns} netpol same-namespace '{.spec.podSelector}')" '{}' && eq "$(j -n %{ns} netpol same-namespace '{.spec.policyTypes}')" '["Ingress"]'"""),
C("front reaches back", 2, NET + r"""
[ -n "$(j -n %{ns} netpol same-namespace '{.metadata.name}')" ] && okr %{ns} front http://back"""),
C("visitor from %{ns}-guest is blocked", 3, NET + r"""
[ -n "$(j -n %{ns} netpol same-namespace '{.metadata.name}')" ] && okr %{ns} front http://back && blocked %{ns}-guest visitor http://back.%{ns}"""),
],
"solution": """
cat <<EOF | k apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: same-namespace, namespace: %{ns}}
spec:
podSelector: {}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {}}] # every Pod - of this Namespace: a bare podSelector never leaves it
EOF
k -n %{ns} exec front -- wget -T 3 -qO- http://back | head -3 # works
k -n %{ns}-guest exec visitor -- wget -T 3 -qO- http://back.%{ns} # times out
# `podSelector: {}` as a peer means "all Pods here"; `namespaceSelector: {}` would mean "all Pods
# everywhere". And `ingress: [{}]` (a rule with no `from`) allows everybody - the opposite.
""",
},
{
"id": 69, "domain": "NET", "title": "Get through the policies that are already there",
"params": lambda r: {"ns": ns_pick(r, "vault")},
"task": """
In Namespace `%{ns}`, Pod `reporter` has to read `http://db` and gets no answer. The Namespace's
NetworkPolicies are correct and are owned by another team: do **not** change, delete or add any.
Make `reporter` able to reach `db` anyway. Pod `stranger` must stay locked out.
""",
"setup": PODS + r"""
mkns %{ns}
srv %{ns} db; cli %{ns} reporter; cli %{ns} stranger
$K apply -f - >/dev/null <<'EOF'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: default-deny, namespace: %{ns}}
spec: {podSelector: {}, policyTypes: [Ingress]}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: {name: db-access, namespace: %{ns}}
spec:
podSelector: {matchLabels: {app: db}}
policyTypes: [Ingress]
ingress:
- from: [{podSelector: {matchLabels: {access: db}}}]
ports: [{protocol: TCP, port: 80}]
EOF
j -n %{ns} netpol '{range .items[*]}{.metadata.name}{" "}{.metadata.uid}{" "}{.metadata.generation}{"\n"}{end}' | sort > "$PRIV/q69.policies"
ready %{ns}
""",
"checks": [
C("reporter reaches db", 3, NET + r"""okr %{ns} reporter http://db"""),
C("the NetworkPolicies are exactly as they were", 2, NET + r"""
okr %{ns} reporter http://db || exit 1
eq "$(j -n %{ns} netpol '{range .items[*]}{.metadata.name}{" "}{.metadata.uid}{" "}{.metadata.generation}{"\n"}{end}' | sort)" "$(cat "$PRIV/q69.policies")" """),
C("stranger is still locked out", 2, NET + r"""okr %{ns} reporter http://db && blocked %{ns} stranger http://db"""),
],
"solution": """
k -n %{ns} get netpol
k -n %{ns} describe netpol db-access # From: PodSelector access=db
k -n %{ns} get pod reporter --show-labels # app=reporter - no access label
k -n %{ns} label pod reporter access=db
k -n %{ns} exec reporter -- wget -T 3 -qO- http://db | head -3
# A policy names its peers by label, so the way in is to carry the label - no policy has to
# change. In a Deployment the label goes into the Pod template (and rolls the Pods).
""",
},
# ================================================================== 5.2 Services ====
{
"id": 70, "domain": "NET", "title": "A Service by port name, with sticky sessions",
"params": lambda r: {"ns": ns_pick(r, "portal"), "app": name_pick(r, "portal"), "port": r.choice([8080, 9090, 8000])},
"task": """
Deployment `%{app}` in Namespace `%{ns}` runs two nginx Pods whose container port is declared
with a **name**.
Create ClusterIP Service `%{app}` for them:
- Service port `%{port}`, named `web`,
- forwarding to the container port **by its name**, not by its number,
- with session affinity, so one client IP keeps reaching the same Pod.
Pod `probe` in the Namespace is there to test with.
""",
"setup": PODS + r"""
mkns %{ns}
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{app}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{app}}}
template:
metadata: {labels: {app: %{app}}}
spec:
containers:
- name: web
image: nginx:1.27
ports: [{name: http-main, containerPort: 80}]
EOF
cli %{ns} probe
$K -n %{ns} rollout status deploy %{app} --timeout=240s >/dev/null
ready %{ns}
""",
"checks": [
C("probe gets the nginx page from http://%{app}:%{port}", 2, NET + r"""
okr %{ns} probe http://%{app}:%{port} && $K -n %{ns} exec probe -- wget -T 3 -qO- http://%{app}:%{port} 2>/dev/null | grep -qi nginx"""),
C("port %{port} is named web and targets the container port by name", 2, r"""
eq "$(j -n %{ns} svc %{app} '{.spec.ports[0].port} {.spec.ports[0].name} {.spec.ports[0].targetPort}')" "%{port} web http-main" """),
C("session affinity by client IP", 1, r"""eq "$(j -n %{ns} svc %{app} '{.spec.sessionAffinity}')" ClientIP"""),
C("ClusterIP Service with both Pods behind it", 1, r"""
eq "$(j -n %{ns} svc %{app} '{.spec.type}')" ClusterIP || exit 1
[ "$(j -n %{ns} endpointslices -l kubernetes.io/service-name=%{app} '{range .items[*].endpoints[*]}{.addresses[0]}{"\n"}{end}' | grep -c .)" = 2 ]"""),
],
"solution": """
k -n %{ns} get deploy %{app} -o jsonpath='{.spec.template.spec.containers[0].ports}' # name: http-main
k -n %{ns} expose deploy %{app} --port=%{port} --target-port=http-main $do > svc.yaml
vi svc.yaml
# spec:
# sessionAffinity: ClientIP
# ports:
# - name: web
# port: %{port}
# targetPort: http-main
k apply -f svc.yaml
k -n %{ns} exec probe -- wget -T 3 -qO- http://%{app}:%{port} | head -3
# A named targetPort follows the Pod: change the container port in the Deployment and the Service
# keeps working. `expose` copies the Deployment's selector, which `create svc` would not.
""",
},
{
"id": 71, "domain": "NET", "title": "A local name for a Service in another Namespace",
"params": lambda r: {"ns": ns_pick(r, "orders")},
"task": """
Applications in Namespace `%{ns}` are configured to call `http://inventory`. The service they need
is `warehouse` in Namespace `%{ns}-data`, and nothing may be deployed in `%{ns}` for it.
1. Create Service `inventory` in `%{ns}` of type **ExternalName** that points at the cluster DNS
name of `warehouse` - the fully qualified one.
2. Write that fully qualified name to `{WS}/q71/target.txt`.
Pod `probe` in `%{ns}` is there to test with.
""",
"setup": PODS + r"""
mkns %{ns}; mkns %{ns}-data
rm -rf "$WS/q71"; mkdir -p "$WS/q71"
srv %{ns}-data warehouse; cli %{ns} probe
ready %{ns}; ready %{ns}-data
""",
"checks": [
C("inventory is an ExternalName Service for warehouse.%{ns}-data.svc.cluster.local", 2, r"""
eq "$(j -n %{ns} svc inventory '{.spec.type} {.spec.externalName}')" "ExternalName warehouse.%{ns}-data.svc.cluster.local" """),
C("probe gets the page from http://inventory", 3, NET + r"""
okr %{ns} probe http://inventory && $K -n %{ns} exec probe -- wget -T 3 -qO- http://inventory 2>/dev/null | grep -qi nginx"""),
C("target.txt holds the fully qualified name", 1, r"""
eq "$(tr -d ' \r\n' < "$WS/q71/target.txt" 2>/dev/null | sed 's/\.$//')" warehouse.%{ns}-data.svc.cluster.local"""),
],
"solution": """
k -n %{ns} create service externalname inventory --external-name warehouse.%{ns}-data.svc.cluster.local
echo warehouse.%{ns}-data.svc.cluster.local > {WS}/q71/target.txt
k -n %{ns} exec probe -- nslookup inventory.%{ns}.svc.cluster.local # a CNAME to the warehouse name
k -n %{ns} exec probe -- wget -T 3 -qO- http://inventory | head -3
# An ExternalName Service is only a DNS alias (a CNAME): no cluster IP, no selector, no proxying,
# and no port mapping - the client still has to use the port the target serves on. The name
# pattern is SERVICE.NAMESPACE.svc.cluster.local.
""",
},
{
"id": 72, "domain": "NET", "title": "Endpoints that never become ready",
"params": lambda r: {"ns": ns_pick(r, "stock"), "app": name_pick(r, "catalog")},
"task": """
In Namespace `%{ns}`, `wget http://%{app}` from Pod `probe` fails. Service `%{app}` is correct -
do not change it - and the two Pods of Deployment `%{app}` are Running.
1. Find out why the Service has no ready endpoints, and fix the **Deployment** so both replicas
serve. The Pods must keep a readiness probe that does an HTTP GET on port 80.
2. Write the HTTP status code the failing probe was getting to `{WS}/q72/code.txt`.
""",
"setup": PODS + r"""
mkns %{ns}; rm -rf "$WS/q72"; mkdir -p "$WS/q72"
$K apply -f - >/dev/null <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{app}, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{app}}}
template:
metadata: {labels: {app: %{app}}}
spec:
containers:
- name: web
image: nginx:1.27
ports: [{containerPort: 80}]
readinessProbe:
httpGet: {path: /healthz, port: 80}
periodSeconds: 3
---
apiVersion: v1
kind: Service
metadata: {name: %{app}, namespace: %{ns}}
spec:
selector: {app: %{app}}
ports: [{port: 80, targetPort: 80}]
EOF
j -n %{ns} svc %{app} '{.metadata.uid} {.spec.selector} {.spec.ports[0].port} {.spec.ports[0].targetPort}' > "$PRIV/q72.svc"
cli %{ns} probe
$K -n %{ns} wait --for=condition=Ready pod/probe --timeout=240s >/dev/null
# the Deployment's Pods are meant to stay un-Ready; wait until they at least run
for _ in $(seq 60); do [ "$(j -n %{ns} pod -l app=%{app} '{.items[*].status.phase}')" = "Running Running" ] && break; sleep 2; done
""",
"checks": [
C("probe gets the nginx page from http://%{app}", 3, NET + r"""okr %{ns} probe http://%{app}"""),
C("both replicas Ready, still with an HTTP readiness probe on port 80", 2, r"""
eq "$(j -n %{ns} deploy %{app} '{.status.readyReplicas}')" 2 || exit 1
[ -n "$(j -n %{ns} deploy %{app} '{.spec.template.spec.containers[0].readinessProbe.httpGet.path}')" ] \
&& eq "$(j -n %{ns} deploy %{app} '{.spec.template.spec.containers[0].readinessProbe.httpGet.port}')" 80"""),
C("the Service is untouched", 1, NET + r"""
okr %{ns} probe http://%{app} || exit 1
eq "$(j -n %{ns} svc %{app} '{.metadata.uid} {.spec.selector} {.spec.ports[0].port} {.spec.ports[0].targetPort}')" "$(cat "$PRIV/q72.svc")" """),
C("code.txt says 404", 1, r"""eq "$(tr -d ' \r\n' < "$WS/q72/code.txt" 2>/dev/null)" 404"""),
],
"solution": """
k -n %{ns} get pod -l app=%{app} # 0/1 Running: started, but never Ready
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{app} # endpoints listed, ready: false
k -n %{ns} describe pod -l app=%{app} | grep -i readiness # probe failed: HTTP statuscode: 404
echo 404 > {WS}/q72/code.txt
k -n %{ns} edit deploy %{app} # readinessProbe.httpGet.path: /healthz -> /
k -n %{ns} rollout status deploy %{app}
k -n %{ns} exec probe -- wget -T 3 -qO- http://%{app} | head -3
# A Service only sends traffic to Ready Pods. Selector and ports were right all along: the Pods
# were held out by their own probe. "Running but 0/1" plus a Service with no ready endpoints is
# always a readiness question first.
""",
},
{
"id": 73, "domain": "NET", "title": "A Service for a Pod it cannot select",
"params": lambda r: {"ns": ns_pick(r, "bridge")},
"task": """
Pod `legacy` runs in Namespace `%{ns}-old` and serves on port 80. Clients in Namespace `%{ns}`
must reach it as `http://legacy-gw`, but a Service selects Pods of its own Namespace only.
In `%{ns}` create:
1. Service `legacy-gw` with **no selector**, port 80.
2. EndpointSlice `legacy-gw-1` that belongs to that Service and carries the IP address of Pod
`legacy` with port 80.
Pod `probe` in `%{ns}` is there to test with.
""",
"setup": PODS + r"""
mkns %{ns}; mkns %{ns}-old
$K -n %{ns}-old run legacy --image=nginx:1.27 --port=80 >/dev/null
cli %{ns} probe
ready %{ns}; ready %{ns}-old
""",
"checks": [
C("legacy-gw has no selector and serves port 80", 1, r"""
[ -n "$(j -n %{ns} svc legacy-gw '{.metadata.name}')" ] && [ -z "$(j -n %{ns} svc legacy-gw '{.spec.selector}')" ] && eq "$(j -n %{ns} svc legacy-gw '{.spec.ports[0].port}')" 80"""),
C("EndpointSlice legacy-gw-1 belongs to it and carries the Pod's IP and port 80", 2, r"""
ip=$(j -n %{ns}-old pod legacy '{.status.podIP}'); [ -n "$ip" ] || exit 1
eq "$(j -n %{ns} endpointslice legacy-gw-1 '{.metadata.labels.kubernetes\.io/service-name} {.addressType} {.endpoints[0].addresses[0]} {.ports[0].port}')" "legacy-gw IPv4 $ip 80" """),
C("probe gets the page from http://legacy-gw", 3, NET + r"""
okr %{ns} probe http://legacy-gw && $K -n %{ns} exec probe -- wget -T 3 -qO- http://legacy-gw 2>/dev/null | grep -qi nginx"""),
],
"solution": """
IP=$(k -n %{ns}-old get pod legacy -o jsonpath='{.status.podIP}')
cat <<EOF | k apply -f -
apiVersion: v1
kind: Service
metadata: {name: legacy-gw, namespace: %{ns}}
spec:
ports: [{port: 80, targetPort: 80}] # no selector: nothing fills in the endpoints
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: legacy-gw-1
namespace: %{ns}
labels: {kubernetes.io/service-name: legacy-gw} # this label is what ties it to the Service
addressType: IPv4
ports: [{port: 80, protocol: TCP}]
endpoints: [{addresses: ["$IP"]}]
EOF
k -n %{ns} exec probe -- wget -T 3 -qO- http://legacy-gw | head -3
# With a selector the control plane writes the EndpointSlices; without one you do. The port name
# in the slice has to equal the Service port's name (both unnamed here). A Pod IP changes when the
# Pod is replaced - this is for addresses that stay put: another cluster, a database outside it.
""",
},
{
"id": 74, "domain": "NET", "title": "Send the traffic to one version",
"params": lambda r: {"ns": ns_pick(r, "store"), "app": name_pick(r, "shop")},
"task": """
In Namespace `%{ns}`, Service `%{app}` spreads requests over the Pods of two Deployments,
`%{app}-v1` and `%{app}-v2`. Version 1 is being retired.
1. Make Service `%{app}` send traffic to the **v2** Pods only. Change the Service; both
Deployments stay as they are and keep running.
2. Request `http://%{app}` from Pod `probe` and write the response to `{WS}/q74/response.txt`.
""",
"setup": PODS + r"""
mkns %{ns}; rm -rf "$WS/q74"; mkdir -p "$WS/q74"
for v in v1 v2; do
$K apply -f - >/dev/null <<EOF
apiVersion: apps/v1
kind: Deployment
metadata: {name: %{app}-$v, namespace: %{ns}}
spec:
replicas: 2
selector: {matchLabels: {app: %{app}, version: $v}}
template:
metadata: {labels: {app: %{app}, 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}]
EOF
done
$K -n %{ns} create service clusterip %{app} --tcp=80:80 >/dev/null
cli %{ns} probe
for v in v1 v2; do $K -n %{ns} rollout status deploy %{app}-$v --timeout=240s >/dev/null; done
ready %{ns}
""",
"checks": [
C("twelve requests to http://%{app} all answer v2", 3, r"""
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
[ "$($K -n %{ns} exec probe -- wget -T 3 -qO- http://%{app} 2>/dev/null | tr -d ' \r\n')" = v2 ] || exit 1
done"""),
C("both Deployments untouched and fully running", 2, r"""
eq "$(j -n %{ns} svc %{app} '{.spec.selector.version}')" v2 || exit 1
for v in v1 v2; do eq "$(j -n %{ns} deploy %{app}-$v '{.metadata.generation} {.status.readyReplicas}')" "1 2" || exit 1; done"""),
C("the Service selects app=%{app}, version=v2", 1, r"""
eq "$(j -n %{ns} svc %{app} '{.spec.selector.app} {.spec.selector.version}')" "%{app} v2" """),
C("response.txt says v2", 1, r"""eq "$(tr -d ' \r\n' < "$WS/q74/response.txt" 2>/dev/null)" v2"""),
],
"solution": """
k -n %{ns} get pod --show-labels # app=%{app},version=v1 | v2
k -n %{ns} get svc %{app} -o wide # SELECTOR app=%{app} - matches both
k -n %{ns} set selector svc %{app} app=%{app},version=v2
k -n %{ns} get endpointslices -l kubernetes.io/service-name=%{app} # two addresses left
k -n %{ns} exec probe -- wget -T 3 -qO- http://%{app} > {WS}/q74/response.txt # v2
# `set selector` replaces the whole selector, so repeat the labels you keep. Narrowing or widening
# a selector is how a Service does blue/green and canary without touching a Deployment.
""",
},
# =================================================================== 5.3 Ingress ====
{
"id": 75, "domain": "NET", "title": "Ingress: two hosts and a default backend",
"params": lambda r: {"ns": ns_pick(r, "edge"), "dom": r.choice(["riverbank", "harbor", "summit", "meadow"])},
"task": """
Namespace `%{ns}` has Services `shop` (port 80), `blog` (port 8080) and `fallback` (port 80).
Create Ingress `%{dom}` with IngressClass `nginx`:
- host `shop.%{dom}.test`, every path: Service `shop` on port 80,
- host `blog.%{dom}.test`, every path: Service `blog` on port 8080,
- any request that matches neither host goes to Service `fallback` on port 80.
(No Ingress controller runs here; the object is graded.)
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create service clusterip shop --tcp=80:80 >/dev/null
$K -n %{ns} create service clusterip blog --tcp=8080:80 >/dev/null
$K -n %{ns} create service clusterip fallback --tcp=80:80 >/dev/null
""",
"checks": [
C("Ingress %{dom} uses IngressClass nginx", 1, r"""eq "$(j -n %{ns} ingress %{dom} '{.spec.ingressClassName}')" nginx"""),
C("shop.%{dom}.test / (Prefix) goes to shop:80", 2, ING + r"""
ing %{ns} %{dom} '
import sys, json
for r in json.load(sys.stdin)["spec"].get("rules", []):
for p in r.get("http", {}).get("paths", []):
b = p["backend"].get("service", {})
if r.get("host") == "shop.%{dom}.test" and p.get("path") == "/" and p.get("pathType") == "Prefix" and b.get("name") == "shop" and b.get("port", {}).get("number") == 80: sys.exit(0)
sys.exit(1)'"""),
C("blog.%{dom}.test / (Prefix) goes to blog:8080", 2, ING + r"""
ing %{ns} %{dom} '
import sys, json
for r in json.load(sys.stdin)["spec"].get("rules", []):
for p in r.get("http", {}).get("paths", []):
b = p["backend"].get("service", {})
if r.get("host") == "blog.%{dom}.test" and p.get("path") == "/" and p.get("pathType") == "Prefix" and b.get("name") == "blog" and b.get("port", {}).get("number") == 8080: sys.exit(0)
sys.exit(1)'"""),
C("the default backend is fallback:80", 2, r"""
eq "$(j -n %{ns} ingress %{dom} '{.spec.defaultBackend.service.name} {.spec.defaultBackend.service.port.number}')" "fallback 80" """),
],
"solution": """
k -n %{ns} create ingress %{dom} --class=nginx \\
--rule="shop.%{dom}.test/*=shop:80" \\
--rule="blog.%{dom}.test/*=blog:8080" \\
--default-backend=fallback:80
k -n %{ns} describe ingress %{dom}
# In --rule the trailing * makes the path a Prefix; without it the path is Exact and only "/"
# itself would match. The default backend is spec.defaultBackend, outside the rules: it takes
# whatever no rule matched. Backend ports are Service ports (blog: 8080), not container ports.
""",
},
{
"id": 76, "domain": "NET", "title": "Ingress: an exact path, a prefix, and a rewrite",
"params": lambda r: {"ns": ns_pick(r, "gate"), "dom": r.choice(["tidewater", "northgate", "copperline", "fairlight"])},
"task": """
Namespace `%{ns}` has Services `status` (port 8080) and `app` (port 80).
Create Ingress `%{dom}-routes` with IngressClass `nginx` for host `%{dom}.example.com`:
- the path `/healthz` - **that path only**, nothing below it - goes to Service `status` on port 8080,
- `/app` **and everything below it** goes to Service `app` on port 80,
- the annotation `nginx.ingress.kubernetes.io/rewrite-target` is set to `/`.
(No Ingress controller runs here; the object is graded.)
""",
"setup": NS + r"""
mkns %{ns}
$K -n %{ns} create service clusterip status --tcp=8080:80 >/dev/null
$K -n %{ns} create service clusterip app --tcp=80:80 >/dev/null
""",
"checks": [
C("IngressClass nginx, one rule for %{dom}.example.com", 1, r"""
eq "$(j -n %{ns} ingress %{dom}-routes '{.spec.ingressClassName} {.spec.rules[*].host}')" "nginx %{dom}.example.com" """),
C("/healthz is Exact and goes to status:8080", 2, ING + r"""
ing %{ns} %{dom}-routes '
import sys, json
for r in json.load(sys.stdin)["spec"].get("rules", []):
for p in r.get("http", {}).get("paths", []):
b = p["backend"].get("service", {})
if p.get("path") == "/healthz" and p.get("pathType") == "Exact" and b.get("name") == "status" and b.get("port", {}).get("number") == 8080: sys.exit(0)
sys.exit(1)'"""),
C("/app is a Prefix and goes to app:80", 2, ING + r"""
ing %{ns} %{dom}-routes '
import sys, json
for r in json.load(sys.stdin)["spec"].get("rules", []):
for p in r.get("http", {}).get("paths", []):
b = p["backend"].get("service", {})
if p.get("path") == "/app" and p.get("pathType") == "Prefix" and b.get("name") == "app" and b.get("port", {}).get("number") == 80: sys.exit(0)
sys.exit(1)'"""),
C("rewrite-target annotation is /", 1, r"""
eq "$(j -n %{ns} ingress %{dom}-routes '{.metadata.annotations.nginx\.ingress\.kubernetes\.io/rewrite-target}')" / """),
],
"solution": """
k -n %{ns} create ingress %{dom}-routes --class=nginx \\
--rule="%{dom}.example.com/healthz=status:8080" \\
--rule="%{dom}.example.com/app*=app:80" \\
--annotation nginx.ingress.kubernetes.io/rewrite-target=/
k -n %{ns} get ingress %{dom}-routes -o yaml | grep -B1 -A1 pathType
# No * : Exact - /healthz matches, /healthz/live does not. With * : Prefix - /app, /app/ and
# /app/cart all match (by path element, so not /application). Annotations are how a controller's
# own features are switched on; they mean nothing to any other controller.
""",
},
]