"""CKAD tasks 100-105: Application Environment, Configuration and Security - the pool tasks that take the domain from 11 to 17. 4.2 Access 100 one may read, one may change 4.3 Quotas 101 a quota that stops a Deployment halfway 4.4 ConfigMaps 102 an env file, and a file under another name 4.6 Secrets 105 every key as a variable, one key as a file 4.7 ServiceAccounts 104 an identity for an app that calls the API 4.8 Security 103 user, group and a volume the group may write Permissions are asked of the API server as the subject, quotas are read from their own status, and what a Pod is given - variables, files, modes, ids - is read inside the running container. Every task has a scripted solution in solve.py. """ from ckad.common import C, CAN, ONE, PY, ROLL, NS, ns_pick, name_pick PEOPLE = ["dana", "omar", "priya", "lucas", "mei", "tariq", "ines", "kofi"] QUESTIONS = [ # ========================================================================= 4.2 Access ==== { "id": 100, "domain": "ENV", "title": "One may read, one may change", "params": lambda r: (lambda p: {"ns": ns_pick(r, "team"), "dev": p[0], "lead": p[1]})(r.sample(PEOPLE, 2)), "task": """ Give two colleagues access to Namespace `%{ns}` — and to nothing else in the cluster. Use the built-in ClusterRoles where one fits. - User `%{dev}`: read-only. RoleBinding `%{dev}-view` to the built-in ClusterRole `view`. - `%{dev}` also restarts Pods by deleting them: Role `pod-restarter` (delete on Pods, nothing more) and RoleBinding `%{dev}-restart`. - User `%{lead}`: may change workloads. RoleBinding `%{lead}-edit` to the built-in ClusterRole `edit`. Then answer three questions in `{WS}/q100/answers.txt`, one `yes` or `no` per line, in this order: 1. Can `%{dev}` read the logs of Pods in `%{ns}`? 2. Can `%{dev}` read Secrets in `%{ns}`? 3. Can `%{lead}` create RoleBindings in `%{ns}`? """, "setup": NS + r"""mkns %{ns}; rm -rf "$WS/q100"; mkdir -p "$WS/q100" """, "checks": [ C("%{dev} has view in %{ns} through RoleBinding %{dev}-view", 3, CAN + r""" eq "$(j -n %{ns} rolebinding %{dev}-view '{.roleRef.kind}/{.roleRef.name}')" ClusterRole/view || exit 1 can --as=%{dev} -- list pods -n %{ns} && can --as=%{dev} -- get pods/log -n %{ns} && cannot --as=%{dev} -- get secrets -n %{ns} && cannot --as=%{dev} -- create deployments -n %{ns}"""), C("and may delete Pods there, through Role pod-restarter - and nothing else", 2, CAN + PY + r""" py -n %{ns} role pod-restarter 'len(o["rules"]) == 1 and o["rules"][0]["resources"] == ["pods"] and o["rules"][0]["verbs"] == ["delete"]' || exit 1 eq "$(j -n %{ns} rolebinding %{dev}-restart '{.roleRef.kind}/{.roleRef.name}')" Role/pod-restarter \ && can --as=%{dev} -- delete pods -n %{ns} && cannot --as=%{dev} -- delete deployments -n %{ns} && cannot --as=%{dev} -- delete configmaps -n %{ns}"""), C("%{lead} has edit in %{ns} through RoleBinding %{lead}-edit", 3, CAN + r""" eq "$(j -n %{ns} rolebinding %{lead}-edit '{.roleRef.kind}/{.roleRef.name}')" ClusterRole/edit || exit 1 can --as=%{lead} -- create deployments -n %{ns} && can --as=%{lead} -- get secrets -n %{ns} && cannot --as=%{lead} -- create rolebindings -n %{ns}"""), C("neither has anything in another Namespace, and answers.txt is right", 2, CAN + r""" can --as=%{lead} -- create deployments -n %{ns} && cannot --as=%{lead} -- list pods -n default && cannot --as=%{dev} -- list pods -n default && cannot --as=%{dev} -- list nodes || exit 1 eq "$(tr 'A-Z' 'a-z' < "$WS/q100/answers.txt" 2>/dev/null | tr -cd 'a-z\n' | grep . | tr '\n' ',')" yes,no,no,"""), ], "solution": """ k -n %{ns} create rolebinding %{dev}-view --clusterrole=view --user=%{dev} k -n %{ns} create role pod-restarter --verb=delete --resource=pods k -n %{ns} create rolebinding %{dev}-restart --role=pod-restarter --user=%{dev} k -n %{ns} create rolebinding %{lead}-edit --clusterrole=edit --user=%{lead} k -n %{ns} auth can-i get pods --subresource=log --as=%{dev} # yes k -n %{ns} auth can-i get secrets --as=%{dev} # no - `view` leaves Secrets out k -n %{ns} auth can-i create rolebindings --as=%{lead} # no - `edit` cannot hand out permissions; `admin` can printf 'yes\\nno\\nno\\n' > {WS}/q100/answers.txt # A RoleBinding may point at a ClusterRole: the rules then hold in the binding's Namespace only. # Permissions add up across bindings - %{dev} has view plus one verb more. To `auth can-i`, a # subresource is --subresource=log; `can-i get pods/log` would ask about a Pod named "log". """, }, # ========================================================================= 4.3 Quotas ==== { "id": 101, "domain": "ENV", "title": "A quota that stops a Deployment halfway", "params": lambda r: {"ns": ns_pick(r, "squad"), "dep": name_pick(r, "worker")}, "task": """ Deployment `%{dep}` in Namespace `%{ns}` asks for 4 replicas and gets 2. The Namespace has a ResourceQuota, `team-quota`. 1. Find out which resource of the quota is exhausted, and write its name as the quota lists it (for example `limits.memory`) to `{WS}/q101/resource.txt`. 2. The application needs only half of what it requests of that resource. Change the **Deployment** — the quota stays as it is — so that all 4 replicas run. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q101"; mkdir -p "$WS/q101" $K -n %{ns} create quota team-quota --hard=requests.cpu=1,requests.memory=1Gi,pods=10 >/dev/null $K apply -f - >/dev/null <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: {name: %{dep}, namespace: %{ns}} spec: replicas: 4 # Recreate, not the default: under a quota this tight a rolling update cannot make room for its # first new Pods while the old ones still count, and stalls (see the solution's note) strategy: {type: Recreate} selector: {matchLabels: {app: %{dep}}} template: metadata: {labels: {app: %{dep}}} spec: terminationGracePeriodSeconds: 2 containers: - name: worker image: busybox:1.36 command: ["sleep", "86400"] resources: requests: {cpu: 400m, memory: 64Mi} EOF for i in $(seq 1 60); do [ "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" = 2 ] && break; sleep 2; done eq "$(j -n %{ns} deploy %{dep} '{.status.availableReplicas}')" 2 && j -n %{ns} quota team-quota '{.metadata.uid}' > "$PRIV/q101.uid" """, "checks": [ C("resource.txt names the exhausted resource", 3, ONE + r"""eq "$(one "$WS/q101/resource.txt")" requests.cpu"""), C("the quota is the one that was there, unchanged", 2, r""" eq "$(j -n %{ns} quota team-quota '{.metadata.uid}')" "$(cat "$PRIV/q101.uid")" && eq "$(j -n %{ns} quota team-quota '{.spec.hard.requests\.cpu}')" 1 \ && eq "$(j -n %{ns} quota team-quota '{.spec.hard.pods}')" 10 && eq "$(j -n %{ns} deploy %{dep} '{.spec.replicas}')" 4 \ && [ "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].resources.requests.cpu}')" != 400m ]"""), C("each replica requests 200m CPU, and all 4 run", 5, ROLL + r""" eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].resources.requests.cpu}')" 200m \ && eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.containers[0].resources.requests.memory}')" 64Mi && rolled %{ns} %{dep} 4 120"""), ], "solution": """ k -n %{ns} get deploy,rs # 2/4 k -n %{ns} describe rs | grep -A2 Warning # FailedCreate ... exceeded quota: team-quota, requested: requests.cpu=400m, # # used: requests.cpu=800m, limited: requests.cpu=1 k -n %{ns} describe quota team-quota # Used / Hard per resource echo requests.cpu > {WS}/q101/resource.txt k -n %{ns} set resources deploy/%{dep} --requests=cpu=200m,memory=64Mi k -n %{ns} rollout status deploy/%{dep} # A Pod that would exceed the quota is not created at all - so there is no Pending Pod to describe: # the refusal is an event on the ReplicaSet. A quota on requests or limits also makes every Pod in # the Namespace declare that resource (or a LimitRange must supply a default). # This Deployment uses the Recreate strategy for a reason: during a rolling update old and new Pods # count against the quota together, and with the quota already full the first new Pods cannot be # created while the old ones may not yet be removed - the rollout would hang until you scale down. """, }, # ===================================================================== 4.4 ConfigMaps ==== { "id": 102, "domain": "ENV", "title": "An env file, and a file under another name", "params": lambda r: {"ns": ns_pick(r, "mail"), "cm": name_pick(r, "relay"), "pod": name_pick(r, "sender"), "level": r.choice(["debug", "info", "warn"])}, "task": """ `{WS}/q102/` holds two files of settings: `logging.env` (one `KEY=value` per line) and `app.properties`. In Namespace `%{ns}`: 1. Create ConfigMap `%{cm}-env` in which every line of `logging.env` is a key of its own. 2. Create ConfigMap `%{cm}-files` holding the file `app.properties` under the key `application.properties` — not under its file name. 3. Create Pod `%{pod}` (`busybox:1.36`, command `sleep 86400`) that - gets every key of `%{cm}-env` as an environment variable with the prefix `LOG_` (so `LEVEL` becomes `LOG_LEVEL`), without naming the keys one by one; - mounts the key `application.properties` as the file `/etc/app/conf/app.properties` — the only thing under `/etc/app` — with file mode `0440`. """, "setup": NS + r""" mkns %{ns}; rm -rf "$WS/q102"; mkdir -p "$WS/q102" printf 'LEVEL=%{level}\nFORMAT=json\n' > "$WS/q102/logging.env" printf 'smtp.host=relay.%{ns}.svc\nsmtp.port=2525\nretry.max=5\n' > "$WS/q102/app.properties" cp "$WS/q102/app.properties" "$PRIV/q102.properties" """, "checks": [ C("ConfigMap %{cm}-env has one key per line of logging.env", 2, PY + r""" py -n %{ns} cm %{cm}-env 'o.get("data") == {"LEVEL": "%{level}", "FORMAT": "json"}'"""), C("ConfigMap %{cm}-files holds the file under the key application.properties", 2, r""" eq "$(j -n %{ns} cm %{cm}-files '{.data.application\.properties}')" "$(cat "$PRIV/q102.properties")" && [ -z "$(j -n %{ns} cm %{cm}-files '{.data.app\.properties}')" ]"""), C("the Pod has LOG_LEVEL and LOG_FORMAT, taken with envFrom and a prefix", 3, PY + r""" py -n %{ns} pod %{pod} 'any(e.get("prefix") == "LOG_" and e.get("configMapRef", {}).get("name") == "%{cm}-env" for e in o["spec"]["containers"][0].get("envFrom", []))' || exit 1 e=$($K -n %{ns} exec %{pod} -- env 2>/dev/null); echo "$e" | grep -qx 'LOG_LEVEL=%{level}' && echo "$e" | grep -qx 'LOG_FORMAT=json'"""), C("/etc/app holds only conf/app.properties, with the file's content and mode 440", 3, r""" eq "$($K -n %{ns} exec %{pod} -- cat /etc/app/conf/app.properties 2>/dev/null)" "$(cat "$PRIV/q102.properties")" \ && eq "$($K -n %{ns} exec %{pod} -- stat -L -c %a /etc/app/conf/app.properties 2>/dev/null)" 440 \ && eq "$($K -n %{ns} exec %{pod} -- sh -c 'ls /etc/app; ls /etc/app/conf' 2>/dev/null | tr '\n' ' ')" "conf app.properties " """), ], "solution": """ cd {WS}/q102 k -n %{ns} create configmap %{cm}-env --from-env-file=logging.env k -n %{ns} create configmap %{cm}-files --from-file=application.properties=app.properties cat </dev/null; } eq "$(x 'id -u')" %{uid} && eq "$(x 'id -g')" %{gid} && x 'id -G' | tr ' ' '\n' | grep -qx %{fsg} \ && eq "$(x 'stat -c %g /work/proof')" %{fsg} && eq "$(x 'stat -c %u /work/proof')" %{uid} && ! x 'touch /ksim-probe' && x 'touch /work/ksim-probe && rm /work/ksim-probe'"""), C("id.txt is the output of id in that container", 3, r""" grep -q 'uid=%{uid}' "$WS/q103/id.txt" 2>/dev/null && grep -q 'gid=%{gid}' "$WS/q103/id.txt" && grep -q '%{fsg}' "$WS/q103/id.txt" """), ], "solution": """ cat </dev/null $K -n %{ns} create configmap other-settings --from-literal=a=b >/dev/null $K apply -f - >/dev/null <<'EOF' apiVersion: apps/v1 kind: Deployment metadata: {name: %{dep}, namespace: %{ns}} spec: replicas: 1 selector: {matchLabels: {app: %{dep}}} template: metadata: {labels: {app: %{dep}}} spec: terminationGracePeriodSeconds: 2 containers: - name: app image: curlimages/curl:8.10.1 command: ["sh", "-c", "while true; do echo status=$(curl -sk -m 3 -o /dev/null -w '%{http_code}' -H \"Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)\" https://kubernetes.default.svc/api/v1/namespaces/%{ns}/configmaps/feature-flags); sleep 5; done"] EOF rolled %{ns} %{dep} 1 240 || exit 1 for i in $(seq 1 20); do $K -n %{ns} logs deploy/%{dep} --tail=1 2>/dev/null | grep -qx 'status=403' && exit 0; sleep 2; done echo "the app does not log status=403" >&2; exit 1 """, "checks": [ C("the Deployment runs as ServiceAccount %{dep}", 3, ROLL + r""" eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.serviceAccountName}')" %{dep} && [ -n "$(j -n %{ns} sa %{dep} '{.metadata.name}')" ] && rolled %{ns} %{dep} 1 60"""), C("which may get ConfigMap feature-flags, and no other ConfigMap, nor list them", 3, CAN + r""" S=system:serviceaccount:%{ns}:%{dep} eq "$(j -n %{ns} rolebinding %{dep} '{.roleRef.kind}/{.roleRef.name}')" Role/%{dep} || exit 1 can --as=$S -- get configmap/feature-flags -n %{ns} && cannot --as=$S -- get configmap/other-settings -n %{ns} && cannot --as=$S -- list configmaps -n %{ns} && cannot --as=$S -- get secrets -n %{ns}"""), C("the application now logs status=200", 4, ROLL + r""" eq "$(j -n %{ns} deploy %{dep} '{.spec.template.spec.serviceAccountName}')" %{dep} && rolled %{ns} %{dep} 1 60 || exit 1 p=$(newest %{ns} app=%{dep}); for i in 1 2 3 4 5 6; do $K -n %{ns} logs "$p" --tail=1 2>/dev/null | grep -qx 'status=200' && exit 0; sleep 3; done; exit 1"""), ], "solution": """ k -n %{ns} logs deploy/%{dep} --tail=2 # status=403 k -n %{ns} create sa %{dep} k -n %{ns} create role %{dep} --verb=get --resource=configmaps --resource-name=feature-flags k -n %{ns} create rolebinding %{dep} --role=%{dep} --serviceaccount=%{ns}:%{dep} k -n %{ns} set serviceaccount deploy/%{dep} %{dep} k -n %{ns} rollout status deploy/%{dep} k -n %{ns} logs deploy/%{dep} --tail=2 # status=200 # The token a Pod finds under /var/run/secrets/kubernetes.io/serviceaccount is its ServiceAccount's: # that is who the API server sees. --resource-name narrows the rule to one object (and works for # get, not for list). In --serviceaccount the form is :. """, }, # ======================================================================== 4.6 Secrets ==== { "id": 105, "domain": "ENV", "title": "Every key as a variable, one key as a file", "params": lambda r: {"ns": ns_pick(r, "ledger"), "sec": name_pick(r, "db"), "pod": name_pick(r, "client"), "pw": "pw-" + "".join(r.choice("abcdefghjkmnpqrstuvwxyz23456789") for _ in range(12))}, "task": """ Namespace `%{ns}` has two Secrets: `%{sec}` with the keys `USER` and `PASSWORD`, and `%{sec}-ca` with the key `ca.crt`. Create Pod `%{pod}` in `%{ns}` (`busybox:1.36`, command `sleep 86400`) that - gets **every** key of `%{sec}` as an environment variable with the prefix `DB_` — `DB_USER`, `DB_PASSWORD` — without naming the keys one by one; - mounts the key `ca.crt` of `%{sec}-ca`, read-only, as the single file `/etc/ssl/db/ca.crt`. """, "setup": NS + r""" mkns %{ns} $K -n %{ns} create secret generic %{sec} --from-literal=USER=ledger_rw --from-literal=PASSWORD=%{pw} >/dev/null printf -- '-----BEGIN CERTIFICATE-----\nMIIBksim%{pw}\nQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=\n-----END CERTIFICATE-----\n' > "$PRIV/q105.ca" $K -n %{ns} create secret generic %{sec}-ca --from-file=ca.crt="$PRIV/q105.ca" --from-literal=ca.key=must-not-be-mounted >/dev/null """, "checks": [ C("the Pod takes Secret %{sec} with envFrom and the prefix DB_", 3, PY + r""" py -n %{ns} pod %{pod} ' c = o["spec"]["containers"][0] any(e.get("prefix") == "DB_" and e.get("secretRef", {}).get("name") == "%{sec}" for e in c.get("envFrom", [])) and not any("secretKeyRef" in e.get("valueFrom", {}) for e in c.get("env", []))'"""), C("in the container DB_USER and DB_PASSWORD have the Secret's values", 3, r""" e=$($K -n %{ns} exec %{pod} -- env 2>/dev/null); echo "$e" | grep -qx 'DB_USER=ledger_rw' && echo "$e" | grep -qx 'DB_PASSWORD=%{pw}'"""), C("/etc/ssl/db holds ca.crt and nothing else, read-only", 4, PY + r""" eq "$($K -n %{ns} exec %{pod} -- cat /etc/ssl/db/ca.crt 2>/dev/null)" "$(cat "$PRIV/q105.ca")" && eq "$($K -n %{ns} exec %{pod} -- ls /etc/ssl/db 2>/dev/null | tr '\n' ' ')" "ca.crt " || exit 1 py -n %{ns} pod %{pod} ' v = {x["name"] for x in o["spec"]["volumes"] if x.get("secret", {}).get("secretName") == "%{sec}-ca"} any(m["name"] in v and m.get("readOnly") is True for m in o["spec"]["containers"][0].get("volumeMounts", []))'"""), ], "solution": """ cat <