#!/usr/bin/env bash # The containers page shows rootless podman-compose stacks: what they are, what # they expose, and what they cost. # # The rules: # # 1. A volume in use is never offered for deletion. MountCount is a runtime # lock counter, not a usage signal -- it reads zero for a volume a running # container has mounted this second. Trusting it offered to delete the live # Command Center database. This is the rule the whole file exists for. # 2. Grouping is read from the compose labels, never invented. Where the # project name says nothing ("docker" is a directory), the repository does. # 3. Acting on a stack uses plain `podman`, never `podman-compose down`, which # would REMOVE containers this tool did not create. # 4. Exposure means published on every interface. A stopped container exposes # nothing yet, and is reported as such rather than as a live finding. # 5. Pruning names what it removes, by id, from the state that was shown. # 6. The compose edit adds an address and changes nothing else -- variables, # quoting, comments and layout all survive -- and is undone if the file # does not read back exactly as intended. # 7. A refusal states its reason. "Something failed" has let a mutation # through three times on this project. # # Every mutation runs against a stubbed podman. Nothing here starts, stops or # removes a real container, image or volume: the containers on this machine are # a working development database. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-containers" fail() { printf 'containers contract: %s\n' "$1" >&2 exit 1 } [[ -r "$helper" ]] || fail "missing $helper" [[ -x "$helper" ]] || fail 'panama-containers is not executable' work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT calls="$work/calls.log" : >"$calls" # A repository laid out like the real one: the compose file sits well below the # root, in a directory called "docker", which is exactly how podman-compose ends # up naming the project after a directory that means nothing. repo="$work/command-center" compose_dir="$repo/packages/db/docker" mkdir -p "$compose_dir" git -C "$repo" init --quiet 2>/dev/null || fail 'could not create the fixture repository' compose="$compose_dir/compose.yml" # ── a podman that answers, and records what it was asked to do ─────────────── # # State lives in files so a test can change it between calls. Mutating verbs are # recorded and otherwise do nothing, which is the entire point. cat >"$work/ps.json" <"$work/images.json" <<'IMGJSON' [ {"Id":"1111111111112222","Names":["docker.io/library/postgres:16"],"Size":458000000,"Containers":0}, {"Id":"3333333333334444","Names":["pgvector:pg17"],"Size":450000000,"Containers":1} ] IMGJSON # Two volumes exist. docker_kcc-pg-data is mounted by the RUNNING postgres, so # podman does not consider it dangling; the anonymous one it does. cat >"$work/volumes-dangling.json" <<'VOLJSON' [{"Name":"7a830b1a58f2anonymous"}] VOLJSON cat >"$work/df.json" <<'DFJSON' [ {"Type":"Images","Total":2,"Active":1,"RawSize":908000000,"RawReclaimable":458000000}, {"Type":"Containers","Total":3,"Active":2,"RawSize":11886090,"RawReclaimable":10795752}, {"Type":"Local Volumes","Total":2,"Active":1,"RawSize":274720148,"RawReclaimable":84280000} ] DFJSON cat >"$work/podman" <>"$calls" case "\$1 \$2" in "ps -a") cat "$work/ps.json" ;; "images --format") cat "$work/images.json" ;; "system df") cat "$work/df.json" ;; "volume ls") cat "$work/volumes-dangling.json" ;; *) exit 0 ;; esac STUB chmod +x "$work/podman" export PANAMA_CONTAINERS_PODMAN="$work/podman" # His actual compose style: flow sequence, an interpolation containing a colon, # and comments that must survive. cat >"$compose" <<'COMPOSE' services: kcc-postgres: # pgvector is the official Postgres image with the extension preinstalled. image: docker.io/pgvector/pgvector:pg17 ports: ["${POSTGRES_PORT:-5432}:5432"] restart: unless-stopped kcc-redis: image: redis:7-alpine ports: ["127.0.0.1:${REDIS_PORT:-6379}:6379"] COMPOSE cp "$compose" "$work/compose.original" snap() { "$helper" snapshot; } field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } state="$(snap)" || fail 'snapshot failed' # ── 1. A volume in use is never offered for deletion ───────────────────────── unused_volumes="$(printf '%s' "$state" | field "['disk']['unusedVolumes']")" case "$unused_volumes" in *pg-data*) fail 'a volume mounted by a running container was offered for deletion' ;; esac [[ "$unused_volumes" == *anonymous* ]] || fail 'the genuinely unused volume was not found' grep -q 'dangling=true' "$helper" \ || fail 'unused volumes are not read from podman own dangling filter' # Matched as code, not as prose: the comment explaining why MountCount is the # wrong signal is the reason this rule is here, and must not trip it. grep -q 'get("MountCount")' "$helper" \ && fail 'MountCount is being used to decide whether a volume is in use' # ── 2. Grouping comes from the labels, and says something ──────────────────── titles="$(printf '%s' "$state" | field "['projects']")" [[ "$titles" == *"'name': 'docker'"* ]] || fail 'the compose project label was not read' case "$titles" in *"'title': 'docker'"*) fail 'a project named after a directory was shown as "docker"' ;; esac running_first="$(printf '%s' "$state" | field "['projects'][0]['running']")" [[ "$running_first" == "2" ]] || fail "the running project is not first (got $running_first)" # ── 3. Stacks are acted on with podman, never compose down ─────────────────── : >"$calls" "$helper" project-stop docker >/dev/null || fail 'project-stop failed' grep -q '^stop kcc-postgres$' "$calls" || fail 'project-stop did not stop the containers' grep -qi 'compose' "$calls" && fail 'project-stop invoked podman-compose' grep -qE '^(rm|rmi) ' "$calls" && fail 'project-stop removed something' # A verb with nothing to do refuses, and says why. : >"$calls" reason="$(printf '%s' "$("$helper" start kcc-postgres)" | field "['error']")" [[ "$reason" == *"already running"* ]] \ || fail "starting a running container did not say why it refused (got: $reason)" [[ -s "$calls" ]] && grep -qE '^start ' "$calls" && fail 'it started an already-running container' # ── 4. Exposure is published-on-every-interface, and honest about state ────── exposed="$(printf '%s' "$state" | field "['exposed']")" [[ "$exposed" == *kcc-postgres* ]] || fail 'a container published on every interface was not reported' case "$exposed" in *kcc-redis*) fail 'a container bound to loopback was reported as exposed' ;; esac [[ "$exposed" == *"'container': 'planner_db'"* ]] || fail 'a stopped publisher was dropped entirely' printf '%s' "$state" | python3 -c " import json,sys for e in json.load(sys.stdin)['exposed']: if e['container'] == 'planner_db' and e['running']: raise SystemExit('a stopped container was reported as currently reachable') " || fail 'a stopped container was reported as currently reachable' # ── 5. Pruning names what it removes ───────────────────────────────────────── : >"$calls" "$helper" prune-images >/dev/null || fail 'prune-images failed' grep -q '^rmi 111111111111$' "$calls" || fail 'prune-images did not remove the unused image by id' grep -q '^rmi 333333333333$' "$calls" && fail 'prune-images removed an image still in use' grep -q 'image prune' "$calls" && fail 'prune-images used a blanket prune instead of named ids' # Nothing to do refuses with a reason. cat >"$work/images.json" <<'ALLUSED' [{"Id":"3333333333334444","Names":["pgvector:pg17"],"Size":450000000,"Containers":1}] ALLUSED : >"$calls" reason="$(printf '%s' "$("$helper" prune-images)" | field "['error']")" [[ "$reason" == *"in use"* ]] || fail "prune-images did not say why it refused (got: $reason)" grep -qE '^rmi ' "$calls" && fail 'prune-images removed something while refusing' # ── 6. The compose edit adds an address and nothing else ───────────────────── result="$("$helper" bind-local docker kcc-postgres)" || fail 'bind-local failed' reason="$(printf '%s' "$result" | field "['error']")" [[ -z "$reason" ]] || fail "bind-local refused a valid edit: $reason" grep -q 'ports: \["127.0.0.1:\${POSTGRES_PORT:-5432}:5432"\]' "$compose" \ || fail 'the edit did not preserve the interpolation, the quoting, or the flow style' grep -q '# pgvector is the official Postgres image' "$compose" \ || fail 'the edit destroyed a comment' grep -q 'restart: unless-stopped' "$compose" \ || fail 'the edit disturbed the rest of the service' [[ "$(grep -c '' "$compose")" == "$(grep -c '' "$work/compose.original")" ]] \ || fail 'the edit changed the shape of the file' # Twice is a refusal, not a second address. reason="$(printf '%s' "$("$helper" bind-local docker kcc-postgres)" | field "['error']")" [[ -n "$reason" ]] || fail 'binding an already-bound service was allowed' grep -q '127.0.0.1:127.0.0.1' "$compose" && fail 'the address was prepended twice' # A service whose ports it cannot read is refused, not guessed at. cat >"$compose" <<'RANGE' services: kcc-postgres: ports: - "5432:5432" - "5432:5432" RANGE reason="$(printf '%s' "$("$helper" bind-local docker kcc-postgres)" | field "['error']")" [[ "$reason" == *"times"* || "$reason" == *"not clear"* ]] \ || fail "an ambiguous mapping was not refused with a reason (got: $reason)" grep -q '127.0.0.1' "$compose" && fail 'an ambiguous compose file was edited anyway' # ── 7. The tests above must be capable of failing ──────────────────────────── # # Rule 1 is the one that matters, so it is proven to fail: told to use # MountCount, the helper must offer the live database for deletion and be # caught. A guard nobody has seen fail is not a guard. probe="$work/probe-helper" sed 's/"volume", "ls", "--filter", "dangling=true", "--format", "json"/"volume", "ls", "--format", "json"/' \ "$helper" >"$probe" chmod +x "$probe" cat >"$work/volumes-all.json" <<'ALLVOL' [{"Name":"docker_kcc-pg-data","MountCount":0},{"Name":"7a830b1a58f2anonymous","MountCount":0}] ALLVOL sed -i "s|\"volume ls\") cat \"$work/volumes-dangling.json\"|\"volume ls\") cat \"$work/volumes-all.json\"|" "$work/podman" cat >"$work/podman" <>"$calls" case "\$1 \$2" in "ps -a") cat "$work/ps.json" ;; "images --format") cat "$work/images.json" ;; "system df") cat "$work/df.json" ;; "volume ls") cat "$work/volumes-all.json" ;; *) exit 0 ;; esac STUB2 chmod +x "$work/podman" leaked="$("$probe" snapshot | field "['disk']['unusedVolumes']")" [[ "$leaked" == *pg-data* ]] \ || fail 'the volume guard cannot be made to fail, so it proves nothing' printf 'containers contract: ok\n'