Task ids used to jump 1-43 / 101-124 / 401-418 / 501-517. They now run
contiguously in the same order:
1-43 unchanged (base and extra)
44-67 hard pool (was 101-124)
68-85 Simulator Set C (was 401-418)
86-102 Simulator Set D (was 501-517)
Every reference moved with its task: ids, solution keys, workspace paths
({WS}/qNN and the graders' hidden .qNN-* files), temp paths, the one
hardcoded id in code (Q56's derived canary weight), and Q-number
cross-references in the harness, README and the explainer. Each task's
paths were checked to carry only its own new number. History entries and
the local session state were remapped so past attempts stay attached to the
same tasks; retired set A/B ids keep their old numbers and match nothing.
weak-spots is regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mhxm4rsUfuE5HoytYbu2YW
648 lines
33 KiB
Python
648 lines
33 KiB
Python
"""
|
|
CKA track for the exam simulator.
|
|
|
|
TRACK - metadata (name, duration, pass mark, domain table)
|
|
QUESTIONS - list of tasks. Each task:
|
|
id, domain, title, task (markdown shown to candidate),
|
|
setup : bash that seeds the scenario
|
|
checks : list of (name, points, bash) - each check is graded independently
|
|
(exit 0 = pass). Points earned = sum of passing checks, so partial
|
|
credit works the way it does on the real exam.
|
|
disruptive: True => setup runs last, after everything else is scheduled.
|
|
|
|
Scripts get a prelude: $K (kubectl), $WS (workspace), $CP/$W1/$W2/$W3 (node
|
|
names), nx NODE 'cmd' (run a command on a node). {WS}, {CP}, {W2}, {W3},
|
|
{SHELL_CP} etc. are substituted in task text.
|
|
"""
|
|
|
|
TRACK = {
|
|
"id": "cka",
|
|
"name": "Certified Kubernetes Administrator",
|
|
"minutes": 120,
|
|
"pass_pct": 66,
|
|
# tasks per domain drawn into a session (weights follow the exam blueprint)
|
|
"quota": {"ARCH": 5, "WORK": 4, "NET": 4, "STOR": 2, "TSHOOT": 5}, # 20 tasks
|
|
"quota_hard": {"ARCH": 6, "WORK": 5, "NET": 5, "STOR": 3, "TSHOOT": 6}, # 25 tasks, hard ones preferred
|
|
# fixed simulator sets (./exam sim c|d) - excluded from random sampling. Sets A and B were
|
|
# retired on 2026-09-22; their last (fixed, verified) versions are in git history.
|
|
"sets": {"c": "Simulator Set C", "d": "Simulator Set D (killer.sh-style)"},
|
|
"domains": {
|
|
"ARCH": "Cluster Architecture, Installation & Configuration (25%)",
|
|
"WORK": "Workloads & Scheduling (15%)",
|
|
"NET": "Services & Networking (20%)",
|
|
"STOR": "Storage (10%)",
|
|
"TSHOOT": "Troubleshooting (30%)",
|
|
},
|
|
}
|
|
|
|
from tracks.cka.common import C, NS
|
|
|
|
QUESTIONS = [
|
|
# ================================================================ ARCH ====
|
|
{
|
|
"id": 1, "domain": "ARCH", "title": "RBAC for a deployment bot",
|
|
"task": """
|
|
Namespace `rbac-app` already exists.
|
|
|
|
1. Create a ServiceAccount `deploy-bot` in namespace `rbac-app`.
|
|
2. Create a Role `deploy-manager` in `rbac-app` that grants
|
|
get, list, watch, create, update, patch, delete on **deployments** (apps API group) only.
|
|
3. Bind it to the ServiceAccount with a RoleBinding named `deploy-bot-binding`.
|
|
|
|
The ServiceAccount must NOT be able to delete pods, and must have no access
|
|
outside the `rbac-app` namespace. Verify with `kubectl auth can-i`.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns rbac-app
|
|
$K -n rbac-app delete sa deploy-bot --ignore-not-found >/dev/null
|
|
$K -n rbac-app delete role deploy-manager --ignore-not-found >/dev/null
|
|
$K -n rbac-app delete rolebinding deploy-bot-binding --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("ServiceAccount rbac-app/deploy-bot exists", 1, r"""$K -n rbac-app get sa deploy-bot >/dev/null 2>&1"""),
|
|
C("Role deploy-manager and RoleBinding deploy-bot-binding exist", 1,
|
|
r"""$K -n rbac-app get role deploy-manager >/dev/null 2>&1 && $K -n rbac-app get rolebinding deploy-bot-binding >/dev/null 2>&1"""),
|
|
C("deploy-bot can get/list/watch/create/update/patch/delete deployments", 2, r"""
|
|
for v in get list watch create update patch delete; do
|
|
[ "$($K auth can-i $v deployments -n rbac-app --as=system:serviceaccount:rbac-app:deploy-bot 2>/dev/null)" = yes ] || exit 1
|
|
done"""),
|
|
C("deploy-bot cannot delete pods", 1, r"""[ "$($K auth can-i delete pods -n rbac-app --as=system:serviceaccount:rbac-app:deploy-bot 2>/dev/null || true)" = no ]"""),
|
|
C("deploy-bot has no access in namespace default", 1, r"""[ "$($K auth can-i create deployments -n default --as=system:serviceaccount:rbac-app:deploy-bot 2>/dev/null || true)" = no ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 2, "domain": "ARCH", "title": "etcd snapshot",
|
|
"task": """
|
|
Create a snapshot of the cluster's etcd database and save it on the
|
|
**control-plane node** at `/opt/cka/etcd-snapshot.db`.
|
|
|
|
Details you need:
|
|
- etcd listens on `https://127.0.0.1:2379`
|
|
- certificates are under `/etc/kubernetes/pki/etcd/` (`ca.crt`, `server.crt`, `server.key`)
|
|
- "ssh to the control-plane node" is: `{SHELL_CP}`
|
|
- `etcdctl` and `etcdutl` are installed on that node, in `/usr/local/bin`
|
|
|
|
Verify the snapshot with `etcdutl snapshot status` (or `etcdctl snapshot status`).
|
|
""",
|
|
"setup": r"""
|
|
nx "$CP" 'mkdir -p /opt/cka; rm -f /opt/cka/etcd-snapshot.db'
|
|
ensure_etcd_tools || { echo "no etcdctl/etcdutl on $CP - fix with ./etcd-tools.sh, then re-seed"; exit 1; }
|
|
""",
|
|
"checks": [
|
|
C("/opt/cka/etcd-snapshot.db exists on the control-plane node", 3, r"""nx "$CP" 'test -f /opt/cka/etcd-snapshot.db'"""),
|
|
C("snapshot is a real etcd backup (> 100 KB)", 4, r"""[ "$(nx "$CP" 'stat -c %s /opt/cka/etcd-snapshot.db' 2>/dev/null || echo 0)" -gt 100000 ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 3, "domain": "ARCH", "title": "Kustomize overlay",
|
|
"task": """
|
|
A Kustomize base lives in `{WS}/q03/base` (Deployment `store-api` + Service `store-api`).
|
|
|
|
Create a production overlay at `{WS}/q03/overlays/prod` that:
|
|
- deploys everything into namespace `store-prod` (create the Namespace as part of the overlay),
|
|
- adds the common label `env=prod` to all resources,
|
|
- sets the `store-api` Deployment to **3** replicas.
|
|
|
|
Apply it with `kubectl apply -k`.
|
|
""",
|
|
"setup": r"""
|
|
rm -rf "$WS/q03"; mkdir -p "$WS/q03/base"
|
|
cat > "$WS/q03/base/kustomization.yaml" <<'EOF'
|
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
kind: Kustomization
|
|
resources:
|
|
- deployment.yaml
|
|
- service.yaml
|
|
EOF
|
|
cat > "$WS/q03/base/deployment.yaml" <<'EOF'
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: store-api
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app: store-api
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app: store-api
|
|
spec:
|
|
containers:
|
|
- name: api
|
|
image: nginx:1.25
|
|
ports:
|
|
- containerPort: 80
|
|
EOF
|
|
cat > "$WS/q03/base/service.yaml" <<'EOF'
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: store-api
|
|
spec:
|
|
selector:
|
|
app: store-api
|
|
ports:
|
|
- port: 80
|
|
targetPort: 80
|
|
EOF
|
|
$K delete ns store-prod --ignore-not-found --wait=false >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("overlay kustomization.yaml exists", 1, r"""[ -f "$WS/q03/overlays/prod/kustomization.yaml" ]"""),
|
|
C("resources deployed into namespace store-prod", 1, r"""$K -n store-prod get deploy store-api >/dev/null 2>&1 && $K -n store-prod get svc store-api >/dev/null 2>&1"""),
|
|
C("store-api has 3 replicas", 1, r"""[ "$($K -n store-prod get deploy store-api -o jsonpath='{.spec.replicas}')" = 3 ]"""),
|
|
C("Deployment and Service carry label env=prod", 2, r"""[ "$($K -n store-prod get deploy store-api -o jsonpath='{.metadata.labels.env}')" = prod ] && [ "$($K -n store-prod get svc store-api -o jsonpath='{.metadata.labels.env}')" = prod ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 4, "domain": "ARCH", "title": "CustomResourceDefinition",
|
|
"task": """
|
|
1. Create a CRD with:
|
|
- group `data.example.com`, version `v1` (served + storage)
|
|
- kind `Backup`, plural `backups`, singular `backup`, shortName `bk`
|
|
- scope `Namespaced`
|
|
- an OpenAPI v3 schema where `spec` has string fields `schedule` and `target`
|
|
2. Create a `Backup` named `nightly` in namespace `default` with
|
|
`spec.schedule: "0 2 * * *"` and `spec.target: "s3://backups/prod"`.
|
|
|
|
`kubectl get bk` must list it.
|
|
""",
|
|
"setup": r"""$K delete crd backups.data.example.com --ignore-not-found >/dev/null""",
|
|
"checks": [
|
|
C("CRD backups.data.example.com exists and is Namespaced", 1, r"""[ "$($K get crd backups.data.example.com -o jsonpath='{.spec.scope}' 2>/dev/null)" = Namespaced ]"""),
|
|
C("shortName bk works", 1, r"""$K get crd backups.data.example.com -o jsonpath='{.spec.names.shortNames}' | grep -q '"bk"'"""),
|
|
C("Backup nightly has the right schedule", 1, r"""[ "$($K -n default get bk nightly -o jsonpath='{.spec.schedule}' 2>/dev/null)" = "0 2 * * *" ]"""),
|
|
C("Backup nightly has the right target", 1, r"""[ "$($K -n default get bk nightly -o jsonpath='{.spec.target}' 2>/dev/null)" = "s3://backups/prod" ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 5, "domain": "ARCH", "title": "Static pod", "breaks": ["w3-staticpath"], # Q46 relocates W3's staticPodPath
|
|
"task": """
|
|
Create a **static pod** named `static-web` running image `nginx:1.27`
|
|
on node `{W3}` (namespace `default`).
|
|
|
|
You must place the manifest in the kubelet's static pod path on that node.
|
|
Reach the node with: `{SHELL_W3}`
|
|
(check `/var/lib/kubelet/config.yaml` for `staticPodPath`).
|
|
|
|
The mirror pod `static-web-{W3}` must reach Running.
|
|
""",
|
|
"setup": r"""nx "$W3" 'rm -f /etc/kubernetes/manifests/static-web*.yaml' || true""",
|
|
"checks": [
|
|
C("manifest containing static-web is in /etc/kubernetes/manifests on the node", 2, r"""nx "$W3" 'grep -ls static-web /etc/kubernetes/manifests/*.yaml' >/dev/null 2>&1"""),
|
|
C("mirror pod static-web-<node> is Running", 2, r"""[ "$($K -n default get pod "static-web-$W3" -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ]"""),
|
|
C("image is nginx:1.27", 1, r"""[ "$($K -n default get pod "static-web-$W3" -o jsonpath='{.spec.containers[0].image}' 2>/dev/null)" = nginx:1.27 ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 6, "domain": "ARCH", "title": "Certificates: expiry check and user CSR",
|
|
"task": """
|
|
Part A — On the control-plane node (`{SHELL_CP}`) determine the expiry date of the
|
|
**kube-apiserver** serving certificate (use `kubeadm certs check-expiration` or `openssl`).
|
|
Write the expiry line to `{WS}/q06/apiserver-expiry.txt` on your machine.
|
|
|
|
Part B — Onboard a new user `jane`:
|
|
1. Generate a private key and a CSR with CN=jane (openssl).
|
|
2. Create a Kubernetes `CertificateSigningRequest` named `jane`
|
|
(signer `kubernetes.io/kube-apiserver-client`, usage `client auth`).
|
|
3. Approve it so a certificate is issued.
|
|
4. Create a RoleBinding `jane-view` in namespace `default` giving user `jane`
|
|
the built-in ClusterRole `view`.
|
|
""",
|
|
"setup": r"""
|
|
rm -rf "$WS/q06"; mkdir -p "$WS/q06"
|
|
$K delete csr jane --ignore-not-found >/dev/null
|
|
$K -n default delete rolebinding jane-view --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("apiserver-expiry.txt records the real expiry month and year", 2, r"""
|
|
[ -s "$WS/q06/apiserver-expiry.txt" ] || exit 1
|
|
exp=$(nx "$CP" 'openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -enddate' | sed 's/notAfter=//')
|
|
yr=$(echo "$exp" | awk '{print $4}'); mon=$(echo "$exp" | awk '{print $1}')
|
|
grep -q "$yr" "$WS/q06/apiserver-expiry.txt" && grep -qi "$mon" "$WS/q06/apiserver-expiry.txt" """),
|
|
C("CSR jane exists with signer kubernetes.io/kube-apiserver-client", 1, r"""[ "$($K get csr jane -o jsonpath='{.spec.signerName}' 2>/dev/null)" = kubernetes.io/kube-apiserver-client ]"""),
|
|
C("CSR jane approved and certificate issued", 2, r"""$K get csr jane -o jsonpath='{.status.conditions[*].type}' 2>/dev/null | grep -q Approved && [ -n "$($K get csr jane -o jsonpath='{.status.certificate}')" ]"""),
|
|
C("jane can list pods in default (view) but not delete them", 1, r"""[ "$($K auth can-i list pods -n default --as=jane 2>/dev/null || true)" = yes ] && [ "$($K auth can-i delete pods -n default --as=jane 2>/dev/null || true)" = no ]"""),
|
|
],
|
|
},
|
|
# ================================================================ WORK ====
|
|
{
|
|
"id": 7, "domain": "WORK", "title": "Rolling update & scaling",
|
|
"task": """
|
|
Deployment `web-frontend` in namespace `apps` runs `nginx:1.25` with 2 replicas.
|
|
|
|
- Update the image to `nginx:1.27`.
|
|
- Scale to **4** replicas.
|
|
- Configure the rolling update strategy: `maxSurge: 1`, `maxUnavailable: 0`.
|
|
- All 4 replicas must be available when you are done.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns apps
|
|
$K -n apps delete deploy web-frontend --ignore-not-found >/dev/null
|
|
$K apply -f - >/dev/null <<'EOF'
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: web-frontend
|
|
namespace: apps
|
|
spec:
|
|
replicas: 2
|
|
selector:
|
|
matchLabels: {app: web-frontend}
|
|
template:
|
|
metadata:
|
|
labels: {app: web-frontend}
|
|
spec:
|
|
containers:
|
|
- name: nginx
|
|
image: nginx:1.25
|
|
resources:
|
|
requests: {cpu: 50m, memory: 32Mi}
|
|
EOF
|
|
""",
|
|
"checks": [
|
|
C("image is nginx:1.27", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.template.spec.containers[0].image}' 2>/dev/null)" = nginx:1.27 ]"""),
|
|
C("replicas = 4", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.replicas}' 2>/dev/null)" = 4 ]"""),
|
|
C("strategy maxSurge=1 maxUnavailable=0", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.strategy.rollingUpdate.maxSurge}')" = 1 ] && [ "$($K -n apps get deploy web-frontend -o jsonpath='{.spec.strategy.rollingUpdate.maxUnavailable}')" = 0 ]"""),
|
|
C("all 4 replicas available", 1, r"""[ "$($K -n apps get deploy web-frontend -o jsonpath='{.status.availableReplicas}' 2>/dev/null)" = 4 ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 8, "domain": "WORK", "title": "Multi-container pod with shared volume",
|
|
"task": """
|
|
Create a pod `log-shipper` in namespace `apps` with two containers:
|
|
|
|
- `app` (image `busybox:1.36`): appends the current date to `/var/log/app.log` every 2 seconds.
|
|
- `shipper` (image `busybox:1.36`): runs `tail -f /var/log/app.log`.
|
|
|
|
Both containers share an `emptyDir` volume named `logs` mounted at `/var/log`.
|
|
`kubectl logs log-shipper -c shipper` must show the dates written by `app`.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns apps
|
|
$K -n apps delete pod log-shipper --ignore-not-found --wait=false >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("pod log-shipper Running with containers app and shipper", 1, r"""
|
|
p="$K -n apps get pod log-shipper"
|
|
[ "$($p -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] || exit 1
|
|
n=$($p -o jsonpath='{.spec.containers[*].name}'); echo "$n" | grep -qw app && echo "$n" | grep -qw shipper"""),
|
|
C("volume logs is an emptyDir mounted at /var/log in both containers", 2, r"""
|
|
p="$K -n apps get pod log-shipper"
|
|
[ -n "$($p -o jsonpath='{.spec.volumes[?(@.name=="logs")].emptyDir}' 2>/dev/null)" ] || exit 1
|
|
[ "$($p -o jsonpath='{.spec.containers[?(@.name=="app")].volumeMounts[?(@.name=="logs")].mountPath}')" = /var/log ] || exit 1
|
|
[ "$($p -o jsonpath='{.spec.containers[?(@.name=="shipper")].volumeMounts[?(@.name=="logs")].mountPath}')" = /var/log ]"""),
|
|
C("shipper's logs show lines written by app", 1, r"""sleep 3; [ -n "$($K -n apps logs log-shipper -c shipper --tail=2 2>/dev/null)" ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 9, "domain": "WORK", "title": "Scheduling with node affinity and resources",
|
|
"task": """
|
|
One node in the cluster is labelled `disktype=ssd`.
|
|
|
|
Create a Deployment `cache` in namespace `apps`, 2 replicas, image `redis:7-alpine`, that:
|
|
- uses **required** node affinity (`requiredDuringSchedulingIgnoredDuringExecution`)
|
|
so pods only run on nodes with `disktype=ssd` (do not use nodeSelector),
|
|
- sets resource requests `cpu: 100m`, `memory: 64Mi` and limits `cpu: 200m`, `memory: 128Mi`.
|
|
|
|
Both pods must be Running.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns apps
|
|
$K label node "$W3" disktype=ssd --overwrite >/dev/null
|
|
$K label node "$W1" disktype- >/dev/null 2>&1 || true
|
|
$K label node "$W2" disktype- >/dev/null 2>&1 || true
|
|
$K -n apps delete deploy cache --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("deployment cache with 2 replicas", 1, r"""[ "$($K -n apps get deploy cache -o jsonpath='{.spec.replicas}' 2>/dev/null)" = 2 ]"""),
|
|
C("required node affinity set, no nodeSelector", 1, r"""d="$K -n apps get deploy cache"; [ -n "$($d -o jsonpath='{.spec.template.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution}' 2>/dev/null)" ] && [ -z "$($d -o jsonpath='{.spec.template.spec.nodeSelector}')" ]"""),
|
|
C("requests 100m/64Mi and limits 200m/128Mi", 1, r"""
|
|
c='{.spec.template.spec.containers[0].resources'; d="$K -n apps get deploy cache"
|
|
[ "$($d -o jsonpath="$c.requests.cpu}")" = 100m ] && [ "$($d -o jsonpath="$c.requests.memory}")" = 64Mi ] && [ "$($d -o jsonpath="$c.limits.cpu}")" = 200m ] && [ "$($d -o jsonpath="$c.limits.memory}")" = 128Mi ]"""),
|
|
C("both pods available and on the ssd node", 1, r"""
|
|
[ "$($K -n apps get deploy cache -o jsonpath='{.status.availableReplicas}')" = 2 ] || exit 1
|
|
for n in $($K -n apps get pod -l app=cache -o jsonpath='{.items[*].spec.nodeName}'); do [ "$n" = "$W3" ] || exit 1; done"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 10, "domain": "WORK", "title": "ConfigMap and Secret consumption",
|
|
"task": """
|
|
In namespace `apps`:
|
|
1. Create ConfigMap `app-config` with keys `APP_MODE=production` and `LOG_LEVEL=debug`.
|
|
2. Create Secret `db-creds` with keys `username=admin` and `password=S3cr3t!`.
|
|
3. Create pod `config-pod` (image `busybox:1.36`, command `sleep 3600`) that:
|
|
- exposes `APP_MODE` as an env var sourced from the ConfigMap key,
|
|
- exposes **all** keys of `db-creds` as env vars (envFrom),
|
|
- mounts the ConfigMap as a volume at `/etc/config`.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns apps
|
|
$K -n apps delete pod config-pod --ignore-not-found --wait=false >/dev/null
|
|
$K -n apps delete cm app-config --ignore-not-found >/dev/null
|
|
$K -n apps delete secret db-creds --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("ConfigMap app-config and Secret db-creds with correct values", 1, r"""[ "$($K -n apps get cm app-config -o jsonpath='{.data.LOG_LEVEL}' 2>/dev/null)" = debug ] && [ "$($K -n apps get secret db-creds -o jsonpath='{.data.password}' 2>/dev/null | base64 -d)" = 'S3cr3t!' ]"""),
|
|
C("APP_MODE injected via configMapKeyRef", 1, r"""[ "$($K -n apps get pod config-pod -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && [ -n "$($K -n apps get pod config-pod -o jsonpath='{.spec.containers[0].env[?(@.name=="APP_MODE")].valueFrom.configMapKeyRef}')" ] && $K -n apps exec config-pod -- env 2>/dev/null | grep -q '^APP_MODE=production$'"""),
|
|
C("all db-creds keys injected via envFrom", 1, r"""[ -n "$($K -n apps get pod config-pod -o jsonpath='{.spec.containers[0].envFrom[*].secretRef}' 2>/dev/null)" ] && $K -n apps exec config-pod -- env 2>/dev/null | grep -q '^password=S3cr3t!$'"""),
|
|
C("ConfigMap mounted at /etc/config", 1, r"""[ "$($K -n apps exec config-pod -- cat /etc/config/LOG_LEVEL 2>/dev/null)" = debug ]"""),
|
|
],
|
|
},
|
|
# ================================================================= NET ====
|
|
{
|
|
"id": 11, "domain": "NET", "title": "NetworkPolicy",
|
|
"task": """
|
|
Namespace `netpol` has deployments `frontend`, `backend` and `db`
|
|
(labels `app=frontend|backend|db`).
|
|
|
|
Create a NetworkPolicy `backend-policy` in `netpol` so that pods with `app=backend`
|
|
- accept ingress **only** from pods labelled `app=frontend` in the same namespace,
|
|
on TCP port **8080**,
|
|
- reject all other ingress.
|
|
|
|
Do not restrict egress. (This cluster's kindnet enforces NetworkPolicy, so a wrong policy really
|
|
blocks traffic; you are graded on the policy spec.)
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns netpol
|
|
for a in frontend backend db; do
|
|
$K -n netpol create deploy $a --image=nginx:1.25 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
done
|
|
$K -n netpol delete netpol backend-policy --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("policy selects app=backend, policyTypes Ingress only", 2, r"""n="$K -n netpol get netpol backend-policy"; [ "$($n -o jsonpath='{.spec.podSelector.matchLabels.app}' 2>/dev/null)" = backend ] && $n -o jsonpath='{.spec.policyTypes}' | grep -q Ingress && ! $n -o jsonpath='{.spec.policyTypes}' | grep -q Egress"""),
|
|
C("single ingress rule from podSelector app=frontend only", 2, r"""n="$K -n netpol get netpol backend-policy"; [ "$($n -o jsonpath='{.spec.ingress[0].from[0].podSelector.matchLabels.app}' 2>/dev/null)" = frontend ] && [ -z "$($n -o jsonpath='{.spec.ingress[1]}')" ] && [ -z "$($n -o jsonpath='{.spec.ingress[0].from[1]}')" ]"""),
|
|
C("allowed port is TCP 8080", 2, r"""n="$K -n netpol get netpol backend-policy"; [ "$($n -o jsonpath='{.spec.ingress[0].ports[0].port}' 2>/dev/null)" = 8080 ] && { p=$($n -o jsonpath='{.spec.ingress[0].ports[0].protocol}'); [ -z "$p" ] || [ "$p" = TCP ]; }"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 12, "domain": "NET", "title": "NodePort Service",
|
|
"task": """
|
|
Deployment `shop` (namespace `svc`) serves HTTP on container port 80.
|
|
|
|
Expose it with a Service `shop-svc` of type **NodePort**:
|
|
port 80, targetPort 80, nodePort **30080**.
|
|
The Service must have ready endpoints.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns svc
|
|
$K -n svc create deploy shop --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
$K -n svc delete svc shop-svc --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("Service shop-svc is NodePort, port 80 -> targetPort 80", 1, r"""s="$K -n svc get svc shop-svc"; [ "$($s -o jsonpath='{.spec.type}' 2>/dev/null)" = NodePort ] && [ "$($s -o jsonpath='{.spec.ports[0].port}')" = 80 ] && [ "$($s -o jsonpath='{.spec.ports[0].targetPort}')" = 80 ]"""),
|
|
C("nodePort is 30080", 1, r"""[ "$($K -n svc get svc shop-svc -o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null)" = 30080 ]"""),
|
|
C("Service has endpoints", 2, r"""[ -n "$($K -n svc get endpointslices -l kubernetes.io/service-name=shop-svc -o jsonpath='{.items[*].endpoints[*].addresses[*]}' 2>/dev/null)" ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 13, "domain": "NET", "title": "Ingress routing",
|
|
"task": """
|
|
Namespace `ingress-app` has Services `web` (port 80) and `api` (port 8080).
|
|
|
|
Create an Ingress `app-ingress` in `ingress-app`:
|
|
- `ingressClassName: nginx`
|
|
- host `shop.example.com`
|
|
- path `/api` (Prefix) -> service `api` port 8080
|
|
- path `/` (Prefix) -> service `web` port 80
|
|
|
|
(No ingress controller is installed; you are graded on the object.)
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns ingress-app
|
|
$K -n ingress-app create deploy web --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
$K -n ingress-app create deploy api --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
$K -n ingress-app expose deploy web --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
$K -n ingress-app expose deploy api --port=8080 --target-port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
$K -n ingress-app delete ingress app-ingress --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("ingressClassName nginx and host shop.example.com", 1, r"""i="$K -n ingress-app get ingress app-ingress"; [ "$($i -o jsonpath='{.spec.ingressClassName}' 2>/dev/null)" = nginx ] && [ "$($i -o jsonpath='{.spec.rules[0].host}')" = shop.example.com ]"""),
|
|
C("/api (Prefix) -> api:8080", 2, r"""i="$K -n ingress-app get ingress app-ingress"; P='{.spec.rules[0].http.paths[?(@.path=="/api")]'; [ "$($i -o jsonpath="$P.backend.service.name}" 2>/dev/null)" = api ] && [ "$($i -o jsonpath="$P.backend.service.port.number}")" = 8080 ] && [ "$($i -o jsonpath="$P.pathType}")" = Prefix ]"""),
|
|
C("/ (Prefix) -> web:80", 2, r"""i="$K -n ingress-app get ingress app-ingress"; P='{.spec.rules[0].http.paths[?(@.path=="/")]'; [ "$($i -o jsonpath="$P.backend.service.name}" 2>/dev/null)" = web ] && [ "$($i -o jsonpath="$P.backend.service.port.number}")" = 80 ] && [ "$($i -o jsonpath="$P.pathType}")" = Prefix ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 14, "domain": "NET", "title": "Cluster DNS",
|
|
"task": """
|
|
Service `hello` exists in namespace `dns-check`.
|
|
|
|
1. Run a pod `dns-test` (image `busybox:1.36`) in namespace `dns-check`.
|
|
2. From inside it, resolve the Service using its **fully-qualified** DNS name.
|
|
3. Save the FQDN you used (one line) to `{WS}/q14/svc-fqdn.txt`.
|
|
4. Save the full `nslookup` output to `{WS}/q14/nslookup.txt`.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns dns-check
|
|
rm -rf "$WS/q14"; mkdir -p "$WS/q14"
|
|
$K -n dns-check create deploy hello --image=nginx:1.25 --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
$K -n dns-check expose deploy hello --port=80 --dry-run=client -o yaml | $K apply -f - >/dev/null
|
|
$K -n dns-check delete pod dns-test --ignore-not-found --wait=false >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("pod dns-test exists in dns-check", 1, r"""$K -n dns-check get pod dns-test >/dev/null 2>&1"""),
|
|
C("svc-fqdn.txt contains hello.dns-check.svc.cluster.local", 2, r"""grep -qx 'hello.dns-check.svc.cluster.local\.\?' "$WS/q14/svc-fqdn.txt" 2>/dev/null"""),
|
|
C("nslookup.txt contains the service ClusterIP", 1, r"""ip=$($K -n dns-check get svc hello -o jsonpath='{.spec.clusterIP}'); grep -q "$ip" "$WS/q14/nslookup.txt" 2>/dev/null"""),
|
|
],
|
|
},
|
|
# ================================================================ STOR ====
|
|
{
|
|
"id": 15, "domain": "STOR", "title": "PersistentVolume, claim and pod",
|
|
"task": """
|
|
1. Create a PersistentVolume `pv-data`: 1Gi, `hostPath` `/mnt/data`,
|
|
accessMode `ReadWriteOnce`, `storageClassName: manual`, reclaim policy `Retain`.
|
|
2. Create a PersistentVolumeClaim `pvc-data` in namespace `storage` requesting 500Mi
|
|
with `storageClassName: manual` so it binds to `pv-data`.
|
|
3. Create pod `data-pod` (image `nginx:1.25`) in `storage` mounting the claim at `/usr/share/data`.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns storage
|
|
$K -n storage delete pod data-pod --ignore-not-found --wait=false >/dev/null
|
|
$K -n storage delete pvc pvc-data --ignore-not-found >/dev/null
|
|
$K delete pv pv-data --ignore-not-found >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("PV pv-data: 1Gi hostPath /mnt/data, RWO, Retain, class manual", 2, r"""
|
|
pv="$K get pv pv-data"
|
|
[ "$($pv -o jsonpath='{.spec.capacity.storage}' 2>/dev/null)" = 1Gi ] && [ "$($pv -o jsonpath='{.spec.hostPath.path}')" = /mnt/data ] && [ "$($pv -o jsonpath='{.spec.persistentVolumeReclaimPolicy}')" = Retain ] && [ "$($pv -o jsonpath='{.spec.storageClassName}')" = manual ] && $pv -o jsonpath='{.spec.accessModes}' | grep -q ReadWriteOnce"""),
|
|
C("PVC pvc-data Bound to pv-data", 2, r"""[ "$($K -n storage get pvc pvc-data -o jsonpath='{.status.phase}' 2>/dev/null)" = Bound ] && [ "$($K -n storage get pvc pvc-data -o jsonpath='{.spec.volumeName}')" = pv-data ]"""),
|
|
C("data-pod Running with claim mounted at /usr/share/data", 2, r"""[ "$($K -n storage get pod data-pod -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && [ -n "$($K -n storage get pod data-pod -o jsonpath='{.spec.volumes[?(@.persistentVolumeClaim.claimName=="pvc-data")].name}')" ] && $K -n storage get pod data-pod -o jsonpath='{.spec.containers[0].volumeMounts[*].mountPath}' | grep -q /usr/share/data"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 16, "domain": "STOR", "title": "Default StorageClass and dynamic provisioning",
|
|
"task": """
|
|
1. Create a StorageClass `fast-local`:
|
|
provisioner `rancher.io/local-path`, `volumeBindingMode: WaitForFirstConsumer`,
|
|
`reclaimPolicy: Delete`.
|
|
2. Make `fast-local` the **default** StorageClass and ensure the existing
|
|
`standard` class is no longer default.
|
|
3. Create PVC `fast-claim` (200Mi, RWO) in namespace `storage` **without** specifying a storageClassName.
|
|
4. Create pod `fast-pod` (image `nginx:1.25`) in `storage` that mounts `fast-claim` at `/data`.
|
|
The claim must end up Bound.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns storage
|
|
$K -n storage delete pod fast-pod --ignore-not-found --wait=false >/dev/null
|
|
$K -n storage delete pvc fast-claim --ignore-not-found >/dev/null
|
|
$K delete sc fast-local --ignore-not-found >/dev/null
|
|
$K annotate sc standard storageclass.kubernetes.io/is-default-class=true --overwrite >/dev/null
|
|
""",
|
|
"checks": [
|
|
C("StorageClass fast-local: local-path, WaitForFirstConsumer, Delete", 1, r"""sc="$K get sc fast-local"; [ "$($sc -o jsonpath='{.provisioner}' 2>/dev/null)" = rancher.io/local-path ] && [ "$($sc -o jsonpath='{.volumeBindingMode}')" = WaitForFirstConsumer ] && [ "$($sc -o jsonpath='{.reclaimPolicy}')" = Delete ]"""),
|
|
C("fast-local is default and standard is not", 2, r"""[ "$($K get sc fast-local -o jsonpath='{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}' 2>/dev/null)" = true ] && [ "$($K get sc standard -o jsonpath='{.metadata.annotations.storageclass\.kubernetes\.io/is-default-class}')" != true ]"""),
|
|
C("fast-claim picked up fast-local and is Bound", 1, r"""[ "$($K -n storage get pvc fast-claim -o jsonpath='{.spec.storageClassName}' 2>/dev/null)" = fast-local ] && [ "$($K -n storage get pvc fast-claim -o jsonpath='{.status.phase}')" = Bound ]"""),
|
|
C("fast-pod Running with claim at /data", 1, r"""[ "$($K -n storage get pod fast-pod -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && $K -n storage get pod fast-pod -o jsonpath='{.spec.containers[0].volumeMounts[*].mountPath}' | grep -qw /data"""),
|
|
],
|
|
},
|
|
# ============================================================== TSHOOT ====
|
|
{
|
|
"id": 17, "domain": "TSHOOT", "breaks": "w2-kubelet", "title": "Node NotReady", "disruptive": True,
|
|
"task": """
|
|
Node `{W2}` is `NotReady`.
|
|
|
|
Investigate and fix it so the node returns to `Ready`. The fix must survive a
|
|
reboot of the node (i.e. the responsible service must be enabled).
|
|
|
|
Reach the node with: `{SHELL_W2}`
|
|
""",
|
|
"setup": r"""nx "$W2" 'systemctl disable kubelet >/dev/null 2>&1; systemctl stop kubelet'""",
|
|
"checks": [
|
|
C("node is Ready", 4, r"""[ "$($K get node "$W2" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null)" = True ]"""),
|
|
C("kubelet is active", 2, r"""[ "$(nx "$W2" 'systemctl is-active kubelet')" = active ]"""),
|
|
C("kubelet is enabled (survives reboot)", 2, r"""[ "$(nx "$W2" 'systemctl is-enabled kubelet')" = enabled ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 18, "domain": "TSHOOT", "title": "Broken deployment and service",
|
|
"task": """
|
|
The `payments` application in namespace `troubleshoot` is down.
|
|
Users report that the Service `payments` returns no backends and the pods are not healthy.
|
|
|
|
Fix everything necessary so that:
|
|
- the `payments` Deployment has all replicas Available (the app is a plain nginx web server), and
|
|
- the `payments` Service has endpoints.
|
|
|
|
Do not delete the Deployment or the Service; fix them in place.
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns troubleshoot
|
|
$K apply -f - >/dev/null <<'EOF'
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: payments
|
|
namespace: troubleshoot
|
|
spec:
|
|
replicas: 2
|
|
selector:
|
|
matchLabels: {app: payments}
|
|
template:
|
|
metadata:
|
|
labels: {app: payments}
|
|
spec:
|
|
containers:
|
|
- name: payments
|
|
image: nginx:1.99-nope
|
|
ports: [{containerPort: 80}]
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: payments
|
|
namespace: troubleshoot
|
|
spec:
|
|
selector:
|
|
app: payment
|
|
ports:
|
|
- port: 80
|
|
targetPort: 8080
|
|
EOF
|
|
""",
|
|
"checks": [
|
|
C("all payments replicas available", 3, r"""d="$K -n troubleshoot get deploy payments"; r=$($d -o jsonpath='{.spec.replicas}' 2>/dev/null); a=$($d -o jsonpath='{.status.availableReplicas}'); [ -n "$a" ] && [ "$a" = "$r" ]"""),
|
|
C("Service payments has endpoints (selector fixed)", 2, r"""[ -n "$($K -n troubleshoot get endpointslices -l kubernetes.io/service-name=payments -o jsonpath='{.items[*].endpoints[*].addresses[*]}' 2>/dev/null)" ]"""),
|
|
C("Service targetPort matches container port 80", 2, r"""[ "$($K -n troubleshoot get svc payments -o jsonpath='{.spec.ports[0].targetPort}' 2>/dev/null)" = 80 ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 19, "domain": "TSHOOT", "title": "CrashLoopBackOff diagnosis",
|
|
"task": """
|
|
Pod `crasher` in namespace `troubleshoot` keeps restarting.
|
|
|
|
1. Find out why. Save the pod's (current or previous) container logs to
|
|
`{WS}/q19/crasher.log`.
|
|
2. Fix the pod so it stays Running. The application must keep the same name,
|
|
namespace, image and command; it only needs the configuration it is complaining about
|
|
(use the value `db.internal` if a value is required).
|
|
""",
|
|
"setup": NS + r"""
|
|
mkns troubleshoot
|
|
rm -rf "$WS/q19"; mkdir -p "$WS/q19"
|
|
$K -n troubleshoot delete pod crasher --ignore-not-found --wait=true >/dev/null
|
|
$K apply -f - >/dev/null <<EOF
|
|
apiVersion: v1
|
|
kind: Pod
|
|
metadata:
|
|
name: crasher
|
|
namespace: troubleshoot
|
|
spec:
|
|
nodeSelector:
|
|
kubernetes.io/hostname: $W3
|
|
containers:
|
|
- name: app
|
|
image: busybox:1.36
|
|
command: ["sh", "-c", "if [ -z \"\$DB_HOST\" ]; then echo 'FATAL: required environment variable DB_HOST is not set'; exit 1; fi; echo \"connected to \$DB_HOST\"; while true; do sleep 5; done"]
|
|
EOF
|
|
""",
|
|
"checks": [
|
|
C("crasher.log captured and contains the DB_HOST failure", 2, r"""grep -q DB_HOST "$WS/q19/crasher.log" 2>/dev/null"""),
|
|
C("pod crasher Running with DB_HOST set", 3, r"""[ "$($K -n troubleshoot get pod crasher -o jsonpath='{.status.phase}' 2>/dev/null)" = Running ] && [ -n "$($K -n troubleshoot exec crasher -- sh -c 'echo $DB_HOST' 2>/dev/null)" ]"""),
|
|
C("image unchanged (busybox:1.36)", 1, r"""[ "$($K -n troubleshoot get pod crasher -o jsonpath='{.spec.containers[0].image}' 2>/dev/null)" = busybox:1.36 ]"""),
|
|
],
|
|
},
|
|
{
|
|
"id": 20, "domain": "TSHOOT", "breaks": "scheduler", "title": "Control plane component failure", "disruptive": True,
|
|
"task": """
|
|
New pods in the cluster are stuck in `Pending` and are never assigned to a node.
|
|
|
|
Find the failing control-plane component and repair it on the control-plane node
|
|
(`{SHELL_CP}`). Afterwards, newly created pods must be scheduled normally.
|
|
""",
|
|
"setup": r"""
|
|
nx "$CP" '
|
|
f=/etc/kubernetes/manifests/kube-scheduler.yaml
|
|
grep -q "^ *- kube-scheduler$" $f && sed -i "s|^\( *\)- kube-scheduler$|\1- kube-schedulerr|" $f
|
|
' || true
|
|
""",
|
|
"checks": [
|
|
C("kube-scheduler static pod manifest repaired", 3, r"""nx "$CP" 'grep -q "^ *- kube-scheduler$" /etc/kubernetes/manifests/kube-scheduler.yaml'"""),
|
|
C("new pods get scheduled", 5, r"""
|
|
$K -n default delete pod sched-probe --ignore-not-found --wait=true >/dev/null 2>&1
|
|
$K -n default run sched-probe --image=busybox:1.36 --restart=Never -- sleep 300 >/dev/null 2>&1 || exit 1
|
|
n=""; for i in $(seq 1 30); do n=$($K -n default get pod sched-probe -o jsonpath='{.spec.nodeName}' 2>/dev/null); [ -n "$n" ] && break; sleep 2; done
|
|
$K -n default delete pod sched-probe --wait=false >/dev/null 2>&1
|
|
[ -n "$n" ]"""),
|
|
],
|
|
},
|
|
]
|
|
|
|
# ---- aggregate the banks: 1-20 here, 21-43 in bank_extra, 44-67 (hard) in bank_hard,
|
|
# ---- 68-85 / 86-102 in the two fixed simulator sets. Numbering is contiguous - keep it that way.
|
|
from tracks.cka import bank_extra, bank_hard, bank_sim_c, bank_sim_d
|
|
QUESTIONS = (QUESTIONS + bank_extra.QUESTIONS + bank_hard.QUESTIONS
|
|
+ bank_sim_c.QUESTIONS + bank_sim_d.QUESTIONS)
|
|
QUESTIONS.sort(key=lambda q: q["id"])
|
|
_ids = [q["id"] for q in QUESTIONS]
|
|
assert len(_ids) == len(set(_ids)), "duplicate question ids"
|
|
from tracks.cka.solutions import S as _SOL
|
|
for _q in QUESTIONS:
|
|
_q["solution"] = (_q.get("solution") or _SOL.get(_q["id"], "")).strip()
|
|
_q.setdefault("set", None)
|
|
_missing = [q["id"] for q in QUESTIONS if not q["solution"]]
|
|
assert not _missing, f"tasks without a solution: {_missing}"
|