feat(docker-nuke): add self-excluding Docker process discovery

This commit is contained in:
David Ibia
2026-07-19 22:06:49 +01:00
parent 4e1e09559b
commit ca256661b8
2 changed files with 204 additions and 3 deletions
+47 -1
View File
@@ -33,7 +33,6 @@ DUMP_RETENTION=10 # how many dump directories to keep
# shellcheck disable=SC2034 # shellcheck disable=SC2034
LOG_TAIL_LINES=2000 # lines of Docker log to copy (keeps dumps small) LOG_TAIL_LINES=2000 # lines of Docker log to copy (keeps dumps small)
# shellcheck disable=SC2034
DOCKER_APP="/Applications/Docker.app" DOCKER_APP="/Applications/Docker.app"
# shellcheck disable=SC2034 # shellcheck disable=SC2034
DOCKER_LOG_DIR="$HOME/Library/Containers/com.docker.docker/Data/log" DOCKER_LOG_DIR="$HOME/Library/Containers/com.docker.docker/Data/log"
@@ -87,6 +86,53 @@ run_timeout() {
return "$rc" 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' "$*"; } log() { printf '%s\n' "$*"; }
warn() { printf '%s\n' "$*" >&2; } warn() { printf '%s\n' "$*" >&2; }
+157 -2
View File
@@ -115,7 +115,7 @@ test_run_timeout() {
# The override is scoped to a subshell so later tests still see the real one. # The override is scoped to a subshell so later tests still see the real one.
rc=0 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; } bin_exists() { return 1; }
run_timeout 1 sleep 5 run_timeout 1 sleep 5
) || rc=$? ) || rc=$?
@@ -123,7 +123,7 @@ test_run_timeout() {
rc=0 rc=0
out="$( 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; } bin_exists() { return 1; }
run_timeout 5 printf 'fallback-ok' run_timeout 5 printf 'fallback-ok'
)" || rc=$? )" || rc=$?
@@ -131,6 +131,161 @@ test_run_timeout() {
} }
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 " <pid> " 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" <<PROBE
#!/usr/bin/env bash
set -euo pipefail
source "$SCRIPT_DIR/docker-nuke"
DOCKER_APP="/nonexistent/docker-nuke-test-probe-guard"
docker_pids >/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' printf '\n=== summary ===\n'
teardown_tmp teardown_tmp
printf 'ran %d, failed %d\n' "$TESTS_RUN" "$TESTS_FAILED" printf 'ran %d, failed %d\n' "$TESTS_RUN" "$TESTS_FAILED"