box/host/migrate-host.sh
claude-hdb 6f7c3bfd60 fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH
The first drill run where every failure was the RELEASE CODE, not the
environment. 71 passed, 5 failed; all five traced to four bugs:

1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS
   user.box=1 but never removed user.claudebox=1, and legacy_boxes()
   counted the old tag — so retire saw its own freshly-migrated box as
   un-migrated and refused forever ('legacy boxes still exist:
   legacybox'), leaving claudenet + claude-dev behind. Now: a verified
   re-home drops the legacy tag LAST (after the move is proven, so a
   failure anywhere above still leaves the box valid under one tag or
   the other), and legacy_boxes() ignores boxes already carrying
   user.box=1.

2. box expose died with a bare 'could not add the proxy device' — it
   swallowed incus's reason, exactly the sin this repo keeps punishing.
   Now it prints incus's error. And the mechanism is corrected: a VM's
   proxy needs NAT mode, which requires a static NIC address, so expose
   pins the box's current lease first (which also fixes the restart
   caveat — the exposure no longer points at a lease the box may lose).

3. wait_box's 2-minute window was too short: the legacy box was declared
   dead and then every migration check against it passed. 4 minutes.

4. The grok template hunted for a regular file named exactly
   'grok-build' under /home/grok and found nothing — an installer's drop
   may be a SYMLINK, and its binary name is upstream's to choose. Now it
   tries the plausible names and paths, falls back to any executable
   grok*, links both names, and SAYS what it found — or dumps what the
   installer actually left when it finds nothing. The drill likewise
   dumps the on-disk evidence and the cloud-init log on a --version
   failure instead of discarding the box.
2026-07-14 19:56:17 +00:00

153 lines
7.5 KiB
Bash

#!/usr/bin/env bash
# migrate-host.sh — move a host from the pre-0.4.0 'claudebox' stack to 'box'.
#
# The zero-ceremony transition is just install.sh + setup-host.sh: that leaves
# a DUAL-STACK host where legacy boxes (tag user.claudebox=1, claudenet/10.87,
# claude-dev) keep working while new mints land on boxnet/10.88. This script is
# the two things that path does not do:
#
# migrate-host.sh --box <name> re-home ONE legacy box onto the new stack
# migrate-host.sh --all-boxes re-home every legacy box
# migrate-host.sh --retire-legacy remove the legacy stack (refuses while any
# legacy box still exists)
#
# One action per invocation, idempotent, loud about what it did. Re-homing
# PRESERVES the box's authed state (Claude login, git creds — the expensive
# thing); it does not re-mint. The order is load-bearing: tag first (additive,
# reversible), profile last, and verify the box works on its new leg BEFORE
# calling it migrated — a box must never end up tagless or profileless.
#
# NOT 'set -e' around the per-box work: a box that fails one step is reported
# and skipped, not a crash that abandons the rest mid-migration.
set -u
GW_NEW=10.88.0.1
say() { printf 'migrate: %s\n' "$*"; }
warn() { printf 'migrate: WARNING: %s\n' "$*" >&2; }
die() { printf 'migrate: ERROR: %s\n' "$*" >&2; exit 1; }
mode=""
target=""
while [ $# -gt 0 ]; do
case "$1" in
--box) mode=box; target="${2:-}"; shift 2 || die "--box needs a name" ;;
--all-boxes) mode=all; shift ;;
--retire-legacy) mode=retire; shift ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1 (see --help)" ;;
esac
done
[ -n "$mode" ] || die "pick one: --box <name> | --all-boxes | --retire-legacy"
command -v incus >/dev/null || die "incus is not installed on this host"
# The new stack must exist before any box can be re-homed onto it. setup-host
# creates it; refuse rather than move a box onto a network that isn't there.
require_new_stack() {
incus network show boxnet >/dev/null 2>&1 || die "boxnet does not exist — run host/setup-host.sh first"
incus profile show box-net >/dev/null 2>&1 || die "box-net profile does not exist — run host/setup-host.sh first"
}
# A box is LEGACY only if it still carries the old tag and has NOT been
# re-homed. Counting every user.claudebox=1 box was a bug: re-homing ADDS
# user.box=1 without removing the old tag, so --retire-legacy saw its own
# migrated boxes as un-migrated and refused forever.
legacy_boxes() {
local b
for b in $(incus list "user.claudebox=1" -f csv -c n 2>/dev/null); do
[ "$(incus config get "$b" user.box 2>/dev/null)" = 1 ] && continue # already re-homed
echo "$b"
done
}
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
# tool minted), so the new metadata is the claude template's.
rehome_one() {
local b="$1" st
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
if [ "$(incus config get "$b" user.box 2>/dev/null)" = 1 ]; then
say "$b already carries user.box=1 — already re-homed, skipping"; return 0
fi
say "re-homing $b"
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
# valid legacy box (the old tag is untouched) AND now a new one.
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
# 2. Stop, reassign the profile (this is the network move), restart. Incus
# won't reassign a profile on a running instance's NIC cleanly, and the
# box needs a fresh DHCP lease on boxnet anyway.
st="$(incus list "$b" -f csv -c s 2>/dev/null | head -1)"
case "$st" in RUNNING|Running|running) incus stop "$b" >/dev/null 2>&1 || warn "$b: stop was not clean" ;; esac
incus profile assign "$b" box-net \
|| { warn "$b: profile assign failed — it still has user.box=1 but is on the OLD network; fix by hand"; return 1; }
incus start "$b" >/dev/null 2>&1 || { warn "$b: did not restart — start it by hand"; return 1; }
# 3. VERIFY THE EFFECT, not the exit codes (the whole repo's lesson). The box
# must be on 10.88 and actually resolve+reach the internet on its new leg
# before we call it migrated.
local i ip
ip=""
for i in $(seq 1 30); do
ip="$(incus exec "$b" -- ip -4 -o addr show scope global </dev/null 2>/dev/null \
| awk '{for(i=1;i<NF;i++) if($i=="inet" && $(i+1)~/^10\.88\./){split($(i+1),a,"/"); print a[1]; exit}}')"
[ -n "$ip" ] && break
sleep 2
done
[ -n "$ip" ] || { warn "$b: never got a 10.88 address after restart — re-home INCOMPLETE, inspect: incus console $b"; return 1; }
if incus exec "$b" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1; then
# LAST, and only once the move is VERIFIED: drop the legacy tag. Until this
# point the box wears both tags, so a failure anywhere above leaves it a
# valid box under one name or the other — never orphaned. Now it is simply
# a box, and --retire-legacy can see the old stack is empty.
incus config unset "$b" user.claudebox >/dev/null 2>&1 \
|| warn "$b: migrated, but the legacy tag could not be removed — retire-legacy will still see it"
say "$b re-homed: on boxnet ($ip), resolves + reachable, authed state preserved"
return 0
fi
warn "$b is on boxnet ($ip) but cannot resolve — check the new stack's resolver (box doctor)"
return 1
}
case "$mode" in
box)
[ -n "$target" ] || die "--box needs a name"
require_new_stack
rehome_one "$target"
;;
all)
require_new_stack
boxes="$(legacy_boxes)"
[ -n "$boxes" ] || { say "no legacy boxes to re-home"; exit 0; }
rc=0
for b in $boxes; do rehome_one "$b" || rc=1; done
[ "$rc" = 0 ] && say "all legacy boxes re-homed" || warn "some boxes need attention (above)"
exit "$rc"
;;
retire)
# Refuse while any legacy box still references the old stack — removing an
# in-use profile/network fails anyway, and a half-removed stack is worse
# than an intact one.
remaining="$(legacy_boxes)"
if [ -n "$remaining" ]; then
die "legacy boxes still exist: $(echo "$remaining" | tr '\n' ' ')
re-home them first (--all-boxes), or delete them, then retire."
fi
say "no legacy boxes remain — removing the legacy stack"
incus profile delete claude-dev >/dev/null 2>&1 && say "deleted profile claude-dev"
incus network delete claudenet >/dev/null 2>&1 && say "deleted network claudenet"
incus network acl delete claude-isolate >/dev/null 2>&1 && say "deleted ACL claude-isolate"
sudo systemctl disable --now claudebox-firewall.service >/dev/null 2>&1 && say "disabled claudebox-firewall.service"
sudo rm -f /etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall
sudo systemctl daemon-reload
sudo nft delete table inet claudebox >/dev/null 2>&1 && say "deleted nft table inet claudebox"
sudo nft delete table bridge claudebox >/dev/null 2>&1 && say "deleted nft table bridge claudebox"
# Assert the absence — don't trust the removals' exit codes.
left=""
incus network show claudenet >/dev/null 2>&1 && left="$left claudenet"
incus profile show claude-dev >/dev/null 2>&1 && left="$left claude-dev"
sudo nft list table bridge claudebox >/dev/null 2>&1 && left="$left nft-bridge"
[ -z "$left" ] && say "legacy stack retired — this host is now single-stack (box only)" \
|| die "legacy stack NOT fully removed:$left"
;;
esac