#!/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 <&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 # 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 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."