From ca256661b811b706b021b52ad63bd58662e837e0 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Sun, 19 Jul 2026 22:06:49 +0100 Subject: [PATCH] feat(docker-nuke): add self-excluding Docker process discovery --- .local/bin/docker-nuke | 48 +++++++++- .local/bin/test-docker-nuke.sh | 159 ++++++++++++++++++++++++++++++++- 2 files changed, 204 insertions(+), 3 deletions(-) diff --git a/.local/bin/docker-nuke b/.local/bin/docker-nuke index 2dcab19..c5b670c 100755 --- a/.local/bin/docker-nuke +++ b/.local/bin/docker-nuke @@ -33,7 +33,6 @@ DUMP_RETENTION=10 # how many dump directories to keep # shellcheck disable=SC2034 LOG_TAIL_LINES=2000 # lines of Docker log to copy (keeps dumps small) -# shellcheck disable=SC2034 DOCKER_APP="/Applications/Docker.app" # shellcheck disable=SC2034 DOCKER_LOG_DIR="$HOME/Library/Containers/com.docker.docker/Data/log" @@ -87,6 +86,53 @@ run_timeout() { return "$rc" } +# Exact process names to target, in addition to anything under Docker.app. +# NOTE: never use `pgrep -f docker` here — it matches this script's own +# command line and the tool would kill itself. +DOCKER_PROC_NAMES="com.docker.backend com.docker.build com.docker.krun \ +com.docker.vpnkit com.docker.virtualization vpnkit docker" + +docker_pids() { + local self="$$" + local parent="${PPID:-0}" + local name pid comm + { + # `pgrep -f "$DOCKER_APP"` matches any process whose *command line* + # mentions the bundle path — e.g. `tail -f /Applications/Docker.app/...` + # matches even though that process is plainly not Docker. Post-filter + # each candidate by its actual executable so only processes truly + # launched from the bundle survive. Wrapped in `|| true`: under + # `set -o pipefail`, an empty `pgrep -f` match (exit 1) would otherwise + # make this whole statement fail and abort the function under `set -e`. + pgrep -f "$DOCKER_APP" 2>/dev/null | while read -r pid; do + if [ -z "$pid" ]; then continue; fi + # A dead PID (process exited between pgrep and ps) or a `ps` failure + # must yield an empty comm, which matches no case branch below — + # never a crash, never a false positive. + # NOTE: `ps -o comm=` reports the executable path as invoked, not + # canonicalized — a Docker helper ever exec'd by relative path or bare + # name would report a non-bundle comm here and be silently dropped. + comm="$(ps -o comm= -p "$pid" 2>/dev/null)" || true + case "$comm" in + "$DOCKER_APP"/*) printf '%s\n' "$pid" ;; + esac + done || true + + # Exact-name matches are already precise — do NOT apply the comm + # filter here. One job of this branch is catching a hung + # /usr/local/bin/docker CLI, which legitimately lives outside the + # bundle; filtering by $DOCKER_APP prefix would break that. + for name in $DOCKER_PROC_NAMES; do + pgrep -x "$name" 2>/dev/null || true + done + } | sort -un | while read -r pid; do + if [ -z "$pid" ]; then continue; fi + if [ "$pid" = "$self" ]; then continue; fi + if [ "$pid" = "$parent" ]; then continue; fi + printf '%s\n' "$pid" + done +} + log() { printf '%s\n' "$*"; } warn() { printf '%s\n' "$*" >&2; } diff --git a/.local/bin/test-docker-nuke.sh b/.local/bin/test-docker-nuke.sh index 08d97e0..1c57c54 100755 --- a/.local/bin/test-docker-nuke.sh +++ b/.local/bin/test-docker-nuke.sh @@ -115,7 +115,7 @@ test_run_timeout() { # The override is scoped to a subshell so later tests still see the real one. rc=0 ( - # shellcheck disable=SC2329 # invoked indirectly by run_timeout's name lookup + # shellcheck disable=SC2329 # invoked indirectly by run_timeout name lookup bin_exists() { return 1; } run_timeout 1 sleep 5 ) || rc=$? @@ -123,7 +123,7 @@ test_run_timeout() { rc=0 out="$( - # shellcheck disable=SC2329 # invoked indirectly by run_timeout's name lookup + # shellcheck disable=SC2329 # invoked indirectly by run_timeout name lookup bin_exists() { return 1; } run_timeout 5 printf 'fallback-ok' )" || rc=$? @@ -131,6 +131,161 @@ test_run_timeout() { } test_run_timeout +printf '\n=== docker_pids ===\n' + +test_docker_pids() { + # Keep both forms: newline for counting, space-padded for substring checks. + # A space-padded haystack is required — searching newline-separated output + # for " " can never match, which would make these assertions vacuous. + local pids_nl pids_sp + pids_nl="$(docker_pids)" + pids_sp=" $(printf '%s' "$pids_nl" | tr '\n' ' ') " + + # $$ and $PPID are never real candidates in live output (the harness's own + # command line does not contain $DOCKER_APP, and its comm is "bash", which + # matches no exact name), so asserting they are absent from live output + # proves nothing — it would pass even if the self/parent exclusion were + # deleted entirely. Kept below only as an additional sanity check; the + # real safety test stubs discovery so $$ and $PPID genuinely ARE + # candidates, which exercises the exclusion filter itself. + assert_not_contains "sanity: live output has no own PID" "$pids_sp" " $$ " + assert_not_contains "sanity: live output has no parent PID" "$pids_sp" " $PPID " + + local filtered + filtered="$( + # shellcheck disable=SC2329 # invoked indirectly by docker_pids name lookup + pgrep() { printf '%s\n%s\n%s\n' "$$" "$PPID" 99999; } + # shellcheck disable=SC2329 # invoked indirectly by docker_pids name lookup + ps() { printf '%s\n' "$DOCKER_APP/Contents/MacOS/com.docker.backend"; } + docker_pids | tr '\n' ' ' + )" + assert_eq "excludes own and parent PID, keeps others" "99999 " "$filtered" + + # Guard against a vacuous suite: if Docker is running there must be matches. + local count + count="$(printf '%s\n' "$pids_nl" | grep -c . || true)" + if pgrep -x com.docker.backend >/dev/null 2>&1; then + if [ "$count" -gt 0 ]; then + pass "finds Docker processes while Docker is running" + else + fail "finds Docker processes while Docker is running" "got 0 pids" + fi + fi + + # Every returned PID must be a live process. + local pid bad="" + for pid in $pids_nl; do + if ! kill -0 "$pid" 2>/dev/null; then + bad="$bad $pid" + fi + done + assert_eq "all returned PIDs are live" "" "$bad" + + # Output must be numeric and deduped. + local nonnum="" + for pid in $pids_nl; do + case "$pid" in + ''|*[!0-9]*) nonnum="$nonnum [$pid]" ;; + esac + done + assert_eq "all output is numeric" "" "$nonnum" + + local uniq + uniq="$(printf '%s\n' "$pids_nl" | sort -u | grep -c . || true)" + assert_eq "output is deduped" "$count" "$uniq" + + # A decoy process whose name contains "docker" but is not Docker Desktop + # must not be matched. + local decoy="$TMPROOT/docker-nuke-decoy" + cat >"$decoy" <<'STUB' +#!/usr/bin/env bash +sleep 30 +STUB + chmod +x "$decoy" + # The decoy's command line must mention $DOCKER_APP so pgrep -f "$DOCKER_APP" + # actually matches it (the script ignores its args and just sleeps); its + # comm stays "bash", which the comm filter must then reject. Without this, + # pgrep -f never matches the decoy at all and the comm filter is untested. + "$decoy" "$DOCKER_APP/Contents/MacOS/not-really-docker" & + local decoy_pid=$! + sleep 1 + pids_sp=" $(docker_pids | tr '\n' ' ') " + assert_not_contains "ignores decoy named docker-nuke-*" "$pids_sp" " $decoy_pid " + + # Guard against a vacuous pass: with Docker stopped, docker_pids returns + # empty output and the assertion above passes for the wrong reason. + # Reuses the anti-vacuous guard pattern used above. + if pgrep -x com.docker.backend >/dev/null 2>&1; then + if [ -n "$(printf '%s' "$pids_sp" | tr -d '[:space:]')" ]; then + pass "decoy check ran against non-empty output while Docker is running" + else + fail "decoy check ran against non-empty output while Docker is running" "got empty pids" + fi + fi + + kill -9 "$decoy_pid" 2>/dev/null || true + wait "$decoy_pid" 2>/dev/null || true +} +test_docker_pids + +printf '\n=== docker_pids errexit contract ===\n' + +test_docker_pids_errexit() { + # Every call site in docker-nuke invokes docker_pids inside $( ), where bash + # never applies errexit to the failing command that triggered it — so a + # regression that lets docker_pids abort under `set -e` would go completely + # undetected by every other test in this file. Prove the contract with a + # real script, invoked directly (not inside $( )), that calls docker_pids + # as a plain statement under `set -euo pipefail`. + # + # The specific failure mode is `pgrep -f "$DOCKER_APP"` matching zero + # processes (pipefail + no trailing `|| true` -> the pipeline itself exits + # non-zero -> set -e aborts before the exact-name branch ever runs). On a + # machine where Docker Desktop happens to be live, the real $DOCKER_APP + # always has matches, so calling docker_pids unmodified would never + # exercise that zero-match branch and the mutation would go undetected + # regardless of host state. Override DOCKER_APP after sourcing to a path + # guaranteed to match nothing, so the zero-match code path is exercised + # deterministically no matter whether Docker is running on this host. + local probe="$TMPROOT/errexit-probe.sh" + cat >"$probe" </dev/null +printf 'survived\n' +PROBE + chmod +x "$probe" + local probe_out probe_rc=0 + probe_out="$("$probe" 2>&1)" || probe_rc=$? + assert_eq "docker_pids does not abort a set -euo pipefail caller" "0" "$probe_rc" + assert_contains "set -e probe ran to completion" "$probe_out" "survived" +} +test_docker_pids_errexit + +printf '\n=== bash 3.2 syntax compatibility ===\n' + +test_bash32_syntax() { + # bash 3.2 (stock macOS /bin/bash) has a $( ) scanner that does not skip + # comments, so an apostrophe inside a comment inside a command substitution + # opens an unterminated quote and the whole file fails to parse. Guard + # against that regressing silently by parse-checking both files with the + # real system bash on every run. + local out rc + + rc=0 + out="$(/bin/bash -n "$SCRIPT_DIR/docker-nuke" 2>&1)" || rc=$? + assert_eq "docker-nuke parses under /bin/bash -n" "0" "$rc" + assert_eq "docker-nuke -n produces no output" "" "$out" + + rc=0 + out="$(/bin/bash -n "$SCRIPT_DIR/test-docker-nuke.sh" 2>&1)" || rc=$? + assert_eq "test-docker-nuke.sh parses under /bin/bash -n" "0" "$rc" + assert_eq "test-docker-nuke.sh -n produces no output" "" "$out" +} +test_bash32_syntax + printf '\n=== summary ===\n' teardown_tmp printf 'ran %d, failed %d\n' "$TESTS_RUN" "$TESTS_FAILED"