#!/usr/bin/env bash # Bootstraps the kind cluster(s) for the exam tracks on podman and preloads images. # Each exam runs on one Kubernetes minor, and each minor gets its own cluster (1 control-plane + # 3 workers), so you practise on the version you will sit: # ./setup.sh [cka|ckad|cks|all] [--recreate] # cka, cks -> cka-sim-135 (Kubernetes v1.35) ckad -> cka-sim-137 (Kubernetes v1.37) # With no track it sets up the one ./exam defaults to ($CKA_TRACK, else cka). Idempotent: an # existing cluster is reused (its nodes restarted if stopped); --recreate rebuilds one whose # version is wrong. `./exam cluster-env` is the single source of the track -> version -> image map. # Override with CONTAINER_RUNTIME=docker if you ever want docker instead. set -euo pipefail cd "$(dirname "$0")" RT="${CONTAINER_RUNTIME:-podman}" need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: '$1' is not installed. $2"; exit 1; } } need "$RT" "https://podman.io/docs/installation" need kind "https://kind.sigs.k8s.io/docs/user/quick-start/#installation" need kubectl "https://kubernetes.io/docs/tasks/tools/" need python3 "" tracks=() recreate=0 while [ $# -gt 0 ]; do case "$1" in cka|ckad|cks) tracks+=("$1") ;; all) tracks+=(cka ckad cks) ;; --track) tracks+=("${2:?--track needs a value}"); shift ;; --recreate) recreate=1 ;; -h|--help) sed -n '2,10p' "$(basename "$0")" | sed 's/^# \{0,1\}//'; exit 0 ;; # cwd is the script dir *) echo "unknown argument '$1'. usage: ./setup.sh [cka|ckad|cks|all] [--recreate]"; exit 1 ;; esac shift done [ ${#tracks[@]} -gt 0 ] || tracks=("${CKA_TRACK:-cka}") if [ "$RT" = podman ]; then export KIND_EXPERIMENTAL_PROVIDER=podman podman info >/dev/null 2>&1 || { echo "ERROR: podman is not working (try: podman info)."; exit 1; } # kind needs cgroup v2 with controller delegation when podman is rootless. if [ "$(id -u)" != 0 ] && [ "$(podman info --format '{{.Host.CgroupsVersion}}' 2>/dev/null)" != "v2" ]; then echo "ERROR: rootless podman needs cgroup v2 for kind. Either enable cgroup v2 or run setup as root (sudo -E ./setup.sh)." exit 1 fi # inotify limits are the #1 cause of pods stuck in ContainerCreating on multi-node kind for k in fs.inotify.max_user_watches:524288 fs.inotify.max_user_instances:512; do key=${k%%:*}; want=${k##*:}; have=$(sysctl -n "$key" 2>/dev/null || echo 0) [ "$have" -ge "$want" ] || echo "warn: $key=$have (recommend >= $want: sudo sysctl $key=$want)" done else docker info >/dev/null 2>&1 || { echo "ERROR: docker daemon is not running/reachable."; exit 1; } fi # a session in progress keeps its kubectl context and kubectl version (kind create switches the context) prev_ctx=$(kubectl config current-context 2>/dev/null || true) open_session=$(python3 -c 'import glob, json for f in glob.glob(".state/state-*.json"): try: if not json.load(open(f)).get("ended_at"): print(f[13:-5]) except Exception: pass' | head -1) # track -> cluster, deduplicated: CKA and CKS share the v1.35 cluster clusters=() first_ctx="" for t in "${tracks[@]}"; do env_lines=$(./exam --track "$t" cluster-env) || exit 1 eval "$env_lines" # TRACK K8S CLUSTER NODE_IMAGE [ -n "$NODE_IMAGE" ] || { echo "ERROR: no kind node image is pinned for Kubernetes $K8S (KIND_IMAGES in ./exam)."; exit 1; } [ -n "$first_ctx" ] || first_ctx="kind-$CLUSTER" case " ${clusters[*]-} " in *" $CLUSTER|"*) ;; *) clusters+=("$CLUSTER|$K8S|$NODE_IMAGE") ;; esac done # kubectl at the cluster's own version, as the real exam gives you: .state/kubectl//kubectl. # ./exam points .state/kubectl/current at the right one per session and examrc puts it on PATH. # On Linux it comes out of the control-plane node, which ships exactly that kubectl (offline, no # checksum needed); elsewhere the node's Linux binary will not run, so it is downloaded. fetch_kubectl() { local ver=$1 dir=.state/kubectl/$2 cp=$3 os arch url tmp want got if [ -x "$dir/kubectl" ] && "$dir/kubectl" version --client 2>/dev/null | grep -q "Client Version: $ver\$"; then echo " kubectl $ver already in $dir"; return 0 fi os=$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/') mkdir -p "$dir"; tmp=$(mktemp "$dir/.kubectl.XXXXXX") if [ "$os" = linux ] && "$RT" cp "$cp:/usr/bin/kubectl" "$tmp" >/dev/null 2>&1 && chmod +x "$tmp" \ && "$tmp" version --client 2>/dev/null | grep -q "Client Version: $ver\$"; then mv -f "$tmp" "$dir/kubectl"; echo " kubectl $ver (from $cp) -> $dir/kubectl"; return 0 fi url="https://dl.k8s.io/release/$ver/bin/$os/$arch/kubectl" want=$(curl -sfL --max-time 30 "$url.sha256" || true) if [ -n "$want" ] && curl -sfL --max-time 300 "$url" -o "$tmp"; then got=$( (sha256sum "$tmp" 2>/dev/null || shasum -a 256 "$tmp") | awk '{print $1}') if [ "$got" = "$want" ]; then chmod +x "$tmp"; mv -f "$tmp" "$dir/kubectl"; echo " kubectl $ver -> $dir/kubectl"; return 0 fi echo " warn: kubectl $ver checksum mismatch, discarded" fi rm -f "$tmp" echo " warn: could not download kubectl $ver - your own kubectl ($(kubectl version --client 2>/dev/null | head -1)) will be used" } create() { kind create cluster --name "$1" --config kind-config.yaml --image "${KIND_NODE_IMAGE:-$2}" --wait 180s } setup_cluster() { local cluster=$1 k8s=$2 image=$3 ctx="kind-$1" cp="$1-control-plane" ver echo if kind get clusters 2>/dev/null | grep -qx "$cluster"; then # rootless podman does not bring node containers back after a reboot or logout for n in $(kind get nodes --name "$cluster"); do [ "$("$RT" inspect -f '{{.State.Running}}' "$n" 2>/dev/null)" = true ] || { echo "==> starting stopped node $n"; "$RT" start "$n" >/dev/null; } done ver=$("$RT" exec "$cp" cat /kind/version 2>/dev/null || echo unknown) case "$ver" in "$k8s".*) echo "==> kind cluster '$cluster' already exists (Kubernetes $ver), reusing it." ;; *) if [ "$recreate" != 1 ]; then echo "ERROR: kind cluster '$cluster' runs Kubernetes $ver, but its tracks need $k8s." echo " Rebuild it (this deletes everything on it): ./setup.sh ${tracks[*]} --recreate" exit 1 fi echo "==> '$cluster' runs $ver, not $k8s - recreating it" kind delete cluster --name "$cluster" create "$cluster" "$image" ;; esac else echo "==> Creating kind cluster '$cluster' (Kubernetes $k8s) on $RT (1 control-plane + 3 workers)..." create "$cluster" "$image" fi echo "==> Waiting for nodes..." # `kubectl wait` gives up on a refused connection, and restarted nodes need 15-40 s before the # API server answers again - wait for it first local deadline=$((SECONDS + 180)) until kubectl --context "$ctx" get --raw=/readyz >/dev/null 2>&1; do [ $SECONDS -lt $deadline ] || { echo "ERROR: the API server of '$cluster' did not come back within 180 s."; exit 1; } sleep 3 done kubectl --context "$ctx" wait --for=condition=Ready nodes --all --timeout=180s echo "==> Preloading images into the cluster (non-fatal if a pull fails)..." # `kind load docker-image` shells out to the docker CLI internally no matter which # provider the cluster uses, so it can't see images that only exist in podman's # store. Save to a tar and use `kind load image-archive` instead, which is # runtime-agnostic. for img in nginx:1.25 nginx:1.27 busybox:1.36 redis:7-alpine; do if "$RT" pull -q "docker.io/library/$img" >/dev/null 2>&1; then tarfile="$(mktemp)" if "$RT" save -o "$tarfile" "docker.io/library/$img" >/dev/null 2>&1 \ && kind load image-archive --name "$cluster" "$tarfile" >/dev/null 2>&1; then echo " loaded $img" else echo " warn: could not load $img" fi rm -f "$tarfile" else echo " warn: could not pull $img (tasks will pull at runtime)" fi done echo "==> Making sure the exam workdir exists on the control-plane node..." "$RT" exec "$cp" mkdir -p /opt/cka # The real exam gives you vim (and less/jq) on every node; the kind node image is minimal, so # install them once here. Skip with CKA_SKIP_NODE_TOOLS=1. if [ -z "${CKA_SKIP_NODE_TOOLS:-}" ]; then echo "==> Installing editors and tools on the nodes (vim, nano, less, jq)..." for n in $(kind get nodes --name "$cluster"); do if "$RT" exec "$n" sh -c 'command -v vim >/dev/null'; then echo " $n already has vim" continue fi if "$RT" exec "$n" sh -c 'DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq vim nano less jq >/dev/null 2>&1'; then "$RT" exec "$n" sh -c 'printf "set tabstop=2\nset expandtab\nset shiftwidth=2\n" > /root/.vimrc' echo " $n ok" else echo " warn: could not install tools on $n (no outbound network?). Fall back to:" echo " $RT cp $n:/path/file /tmp/f && \$EDITOR /tmp/f && $RT cp /tmp/f $n:/path/file" fi done fi # The exam expects etcdctl/etcdutl on the control-plane node, like the real one. They cannot be # copied out of the running etcd pod (that image is distroless), so install them properly. echo "==> Installing etcdctl/etcdutl on the control-plane node..." printf ' ' CONTAINER_RUNTIME="$RT" CKA_CP="$cp" bash ./etcd-tools.sh || echo " warn: no etcd client on $cp; Q02/Q45 will say so when they seed" echo "==> Fetching kubectl to match the cluster..." fetch_kubectl "$("$RT" exec "$cp" cat /kind/version)" "$k8s" "$cp" echo echo "Cluster '$cluster' ready (context $ctx). Nodes:" kubectl --context "$ctx" get nodes -o wide } for c in "${clusters[@]}"; do IFS='|' read -r cluster k8s image <<<"$c" setup_cluster "$cluster" "$k8s" "$image" done # remember the runtime so the CLI uses the matching exec command mkdir -p .state && echo "$RT" > .state/runtime # `kind create cluster` switches the current context to each new cluster; land on the first track's, # with its kubectl (./exam re-points both at every session start) - unless a session is open, which # keeps what it had first_k8s=$(./exam --track "${tracks[0]}" cluster-env | sed -n 's/^K8S=//p') mkdir -p .state/kubectl if [ -n "$open_session" ]; then [ -n "$prev_ctx" ] && kubectl config use-context "$prev_ctx" >/dev/null echo "note: a $open_session session is still open, so your kubectl context stays $prev_ctx." elif kubectl config use-context "$first_ctx" >/dev/null && [ -x ".state/kubectl/$first_k8s/kubectl" ]; then ln -sfn "$first_k8s" .state/kubectl/current else # no download: current/ -> your own kubectl, so the path examrc puts on PATH always exists own=$(PATH=$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '/\.state/kubectl' | paste -sd: -) command -v kubectl) mkdir -p .state/kubectl/host && ln -sfn "$(realpath "$own")" .state/kubectl/host/kubectl && ln -sfn host .state/kubectl/current fi if kind get clusters 2>/dev/null | grep -qx cka-sim; then echo echo "note: the old single cluster 'cka-sim' (from before each exam got its own version) still exists." echo " Nothing uses it now; free its ~4 GB with: ./teardown.sh legacy" fi chmod +x exam echo echo "Next: ./exam --track ${tracks[0]} web (browser UI) or ./exam --track ${tracks[0]} drill (5 tasks from your least-drilled domain)" echo " ./exam clusters which cluster and Kubernetes version each track uses"