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
685 lines
32 KiB
Python
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.
|
|
""",
|
|
},
|
|
]
|