box/drill/wipe.sh

182 lines
8.6 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
# wipe.sh — scorched earth for drill hosts. Remove EVERY trace of box and of
# pre-0.4.0 claudebox, so the next drill run starts from a truly bare host and
# its verdict means something.
#
# bash drill/wipe.sh # asks first; KEEPS cached images (an
# # image is upstream's artifact — wiping
# # it buys nothing but a re-download)
# bash drill/wipe.sh --yes # no prompt
# bash drill/wipe.sh --purge-storage # also delete the 'default' storage
# # pool (and the images inside it), so
# # setup-host exercises its bootstrap (#29)
#
# What teardown-host.sh does NOT cover, this does: instances the drill names
# but never tagged, instances of either tag generation, cached images, and
# (opt-in) the storage pool. teardown is the polite uninstall; this is the
# reset button for the staging server.
#
# NOT 'set -e': on a wipe, a step that finds nothing to remove is success,
# not failure. Every removal states what it did; silence is never trusted
# (the exit-code lesson, again).
#
# The file is one long 'removal && say "did X" || say "X failed"'. say always
# returns 0, so the C-may-run-when-A-is-true trap SC2015 warns about cannot fire
# here (same reasoning as drill.sh's ok/no).
# shellcheck disable=SC2015
set -u
YES=0; PURGE_STORAGE=0
while [ $# -gt 0 ]; do
case "$1" in
--yes|-y) YES=1; shift ;;
--purge-storage) PURGE_STORAGE=1; shift ;;
-h|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "wipe: unknown option: $1" >&2; exit 2 ;;
esac
done
say() { printf 'wipe: %s\n' "$*"; }
if [ "$YES" -ne 1 ]; then
cat <<EOF
This wipes EVERY trace of box/claudebox from this host ($(hostname)):
· every instance tagged user.box=1 or user.claudebox=1
· every instance the drill has ever named (drill, clone, archive, peer,
payroll, cbprobe, cbcopy, cbnotours, tpl)
· networks boxnet + claudenet, ACLs box-isolate + claude-isolate
· profiles box-net + claude-dev
· firewall units, scripts and nft tables of BOTH name generations
$( [ "$PURGE_STORAGE" = 1 ] && echo " · the 'default' storage pool AND its cached images (--purge-storage)" \
|| echo " · (cached images are KEPT — the next mint stays fast; --purge-storage removes them with the pool)" )
Uncommitted work inside any box is LOST. Only do this on a drill host.
EOF
[ -t 0 ] || { echo "wipe: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
printf 'Continue? [y/N] '
read -r reply
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
fi
command -v incus >/dev/null || { say "incus is not installed — nothing box-shaped can exist; only firewall crumbs checked."; }
if command -v incus >/dev/null; then
# --- instances: both tags, then every name the drill has ever used --------
# One delete at a time — a multi-name 'incus delete' aborts at the first
# missing name (drill trap 5).
for tag in "user.box=1" "user.claudebox=1"; do
for i in $(incus list "$tag" -f csv -c n 2>/dev/null); do
timeout -k 5 60 incus delete -f "$i" >/dev/null 2>&1 \
&& say "deleted instance $i ($tag)" || say "instance $i: delete FAILED — look at it by hand"
done
done
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
incus info "$n" >/dev/null 2>&1 || continue
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1 \
&& say "deleted untagged drill instance $n" || say "instance $n: delete FAILED — look at it by hand"
done
# --- profiles, networks, ACLs — both generations ---------------------------
for p in box-net claude-dev; do
incus profile delete "$p" >/dev/null 2>&1 && say "deleted profile $p"
done
for net in boxnet claudenet; do
incus network delete "$net" >/dev/null 2>&1 && say "deleted network $net"
done
for acl in box-isolate claude-isolate; do
incus network acl delete "$acl" >/dev/null 2>&1 && say "deleted ACL $acl"
done
# --- cached images: NOT wiped by default -----------------------------------
# An image is upstream's artifact, content-addressed by fingerprint — not a
# drill artifact. Deleting it buys zero cleanliness and costs the next mint
# a full re-download. It only goes when the pool it lives in goes.
if [ "$PURGE_STORAGE" = 1 ]; then
# --- the pool (opt-in): lets setup-host's bootstrap run for real ---------
# Images live in the pool and block its deletion — they go first.
for f in $(incus image list -f csv -c f 2>/dev/null); do
incus image delete "$f" >/dev/null 2>&1 && say "deleted image $f"
done
incus profile device remove default root >/dev/null 2>&1 && say "removed default profile's root device"
if incus storage delete default >/dev/null 2>&1; then
say "deleted storage pool 'default' — setup-host will rebuild it (btrfs where it can)"
else
incus storage show default >/dev/null 2>&1 \
&& say "pool 'default' NOT deleted — something still uses it: incus storage volume list default" \
|| say "no 'default' pool existed"
fi
fi
fi
# --- firewall: units, scripts, nft tables, UFW and Docker crumbs -------------
for unit in box-firewall claudebox-firewall; do
sudo systemctl disable --now "$unit.service" >/dev/null 2>&1 && say "disabled $unit.service"
sudo rm -f "/etc/systemd/system/$unit.service" "/usr/local/sbin/$unit"
done
sudo systemctl daemon-reload
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086 # the table spec is two words by design
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
done
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141. It was correct today, and only by accident — the file is `set -u` with no `pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was one line from wrong: adding `set -o pipefail` for unrelated robustness would have silently skipped every UFW removal on a host the operator was told is wiped, with no error and no red X. Measured on a shim: 5/5 runs took the wrong branch under pipefail, 3/3 the right one without. Transplant #106's pattern verbatim from host/teardown-host.sh: capture ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop — whose condition was itself an early-exit reader, plus an un-captured re-read to get the number — as a `while :` that reads one capture per iteration and breaks on absence. The re-scan stays per-delete, since numbers shift after each removal; it just no longer races. Removals keep the file's `cmd && say "did X"` idiom. Generalize the test/cli.sh pin from the one site to the class: sweep every host/*.sh and drill/*.sh for the racing shape and name the offenders, so a new script in either directory inherits the pin instead of being one more site to remember. Comment lines are stripped before matching — each fix's own commentary quotes the racing shape to explain it, and a prose-blind pin would fail on the comment documenting why it exists. The positive pins (the capture, the break-on-absence) now run per file over both, so the sweep cannot be satisfied by deleting a block instead of fixing it. drill/doctor.sh was checked and needs nothing: it already reads into `ufw_out` and is safe by construction, not by absent pipefail. Refs #107
2026-07-19 23:37:03 +00:00
# Every ufw read is CAPTURED before it is matched, never piped into a reader
# that exits on its first match (#102, #107).
#
# `ufw status | grep -q "Status: active"` returns the WRITER's exit: grep
# matches on the first line ufw prints, closes the pipe, ufw takes SIGPIPE,
# and the pipeline yields 141. This file is 'set -u' with no pipefail, so
# that 141 is discarded, grep's 0 is the pipeline's result, and the branch
# held — the defect was latent here, never live. It was also one line from
# live: adding 'set -o pipefail' for unrelated robustness would silently turn
# this into #102, skipping every UFW removal on a host the operator was told
# is wiped, with no error and no red X to see. Captured and matched with
# [[ ]], it is correct under any future 'set' line.
#
# The numbered loop had the same defect for a different reason: its condition
# was also an early-exit reader, so it could end while rules remained, and it
# re-read un-captured to get the number. It now reads one capture per
# iteration and breaks on absence — the re-scan is still per-delete (numbers
# shift after each removal), just no longer racing.
ufw_status=""
if command -v ufw >/dev/null; then
# '|| true': ufw exits non-zero when it cannot read its config, and "no
# usable ufw here" is nothing-to-clean, not a failure to report.
ufw_status="$(sudo ufw status 2>/dev/null || true)"
fi
if [[ "$ufw_status" == *"Status: active"* ]]; then
for net in boxnet claudenet; do
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141. It was correct today, and only by accident — the file is `set -u` with no `pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was one line from wrong: adding `set -o pipefail` for unrelated robustness would have silently skipped every UFW removal on a host the operator was told is wiped, with no error and no red X. Measured on a shim: 5/5 runs took the wrong branch under pipefail, 3/3 the right one without. Transplant #106's pattern verbatim from host/teardown-host.sh: capture ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop — whose condition was itself an early-exit reader, plus an un-captured re-read to get the number — as a `while :` that reads one capture per iteration and breaks on absence. The re-scan stays per-delete, since numbers shift after each removal; it just no longer races. Removals keep the file's `cmd && say "did X"` idiom. Generalize the test/cli.sh pin from the one site to the class: sweep every host/*.sh and drill/*.sh for the racing shape and name the offenders, so a new script in either directory inherits the pin instead of being one more site to remember. Comment lines are stripped before matching — each fix's own commentary quotes the racing shape to explain it, and a prose-blind pin would fail on the comment documenting why it exists. The positive pins (the capture, the break-on-absence) now run per file over both, so the sweep cannot be satisfied by deleting a block instead of fixing it. drill/doctor.sh was checked and needs nothing: it already reads into `ufw_out` and is safe by construction, not by absent pipefail. Refs #107
2026-07-19 23:37:03 +00:00
while :; do
numbered="$(sudo ufw status numbered 2>/dev/null || true)"
line="$(printf '%s\n' "$numbered" | grep -m1 "on $net" || true)"
[ -n "$line" ] || break
n="$(printf '%s\n' "$line" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
[ -n "$n" ] || break
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
done
done
fi
if command -v docker >/dev/null; then
for net in boxnet claudenet; do
sudo iptables -D DOCKER-USER -i "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -i $net"
sudo iptables -D DOCKER-USER -o "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -o $net"
done
fi
# --- verdict: assert the ABSENCE, don't trust the removals' exit codes -------
left=""
if command -v incus >/dev/null; then
for tag in "user.box=1" "user.claudebox=1"; do
[ -n "$(incus list "$tag" -f csv -c n 2>/dev/null)" ] && left="$left instances($tag)"
done
for net in boxnet claudenet; do incus network show "$net" >/dev/null 2>&1 && left="$left $net"; done
for p in box-net claude-dev; do incus profile show "$p" >/dev/null 2>&1 && left="$left $p"; done
fi
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086
sudo nft list table $t >/dev/null 2>&1 && left="$left nft:${t// /-}"
done
if [ -n "$left" ]; then
say "NOT clean — still present:$left"
exit 1
fi
say "clean — no trace of box or claudebox remains. The drill will rebuild everything."