feat: host/migrate-host.sh — re-home legacy boxes onto the new stack, then retire it

The 0.4.0 transition is zero-ceremony (install + setup-host = a
dual-stack host). This script is the two things that path does not do:

- --box <name> / --all-boxes: re-home a pre-rename box onto the new
  stack, PRESERVING its authed state (no re-login). Order is
  load-bearing — tag first (additive, reversible), profile-assign last
  (the network move), then verify the box actually resolves + reaches
  the internet on its 10.88 leg before declaring it migrated. A box
  never ends up tagless or profileless.
- --retire-legacy: remove claudenet/claude-dev/claude-isolate and the
  old firewall unit + nft tables, but REFUSE while any legacy box still
  references them; assert their absence rather than trust exit codes.

Drill phase M builds a faithful legacy stack (claudenet on 10.87, a
claude-dev profile pinned to it, a box on the old tag), then proves:
retire refuses with a legacy box present, re-home flips the tag +
reassigns box-net + lands a 10.88 address + resolves, and retire then
succeeds and leaves nothing. The transition is measured, not asserted.

Closes #53
This commit is contained in:
claude-hdb 2026-07-14 16:02:27 +00:00
parent 9b3522e4ee
commit c9712834f2
2 changed files with 208 additions and 4 deletions

View file

@ -159,8 +159,8 @@ This will, ON THIS HOST ($(hostname)):
· install Incus and a systemd unit
· create a network (boxnet), an ACL, and a profile
· rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain)
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl
· mutate the network and profile mid-run to rehearse the #16 hardening
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl, legacybox
· build a faithful legacy stack (claudenet/10.87, claude-dev) to drill migration
Only do this on a machine you can format.
EOF
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
@ -243,7 +243,7 @@ fi
inf "clearing anything a previous run left behind…"
# One name at a time — 'incus delete -f a b c' aborts at the first MISSING name,
# which is how run 2 inherited run 1's boxes and cascaded five false FAILs.
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl legacybox; do
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
done
if incus network show boxnet >/dev/null 2>&1; then
@ -671,6 +671,73 @@ if [ "$BASELINE_OK" -ne 1 ]; then
inf "start with: bash drill/doctor.sh"
fi
# ===========================================================================
phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)"
# ===========================================================================
# A fresh host has no legacy stack, so build a faithful one: claudenet on the
# OLD subnet, a claude-dev profile pinned to it, and a box tagged with the OLD
# tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove
# migrate-host.sh moves it onto the new stack with its identity intact, and
# retires the legacy stack only once it is empty.
MIG="$HOME/.local/share/claudebox/host/migrate-host.sh"
if [ ! -f "$MIG" ]; then
no "migrate-host.sh not installed — cannot drill the transition"
else
inf "building a faithful legacy stack (claudenet/10.87 + claude-dev)…"
incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \
ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none >/dev/null 2>&1
if ! incus profile show claude-dev >/dev/null 2>&1; then
incus profile create claude-dev >/dev/null 2>&1
incus profile device add claude-dev root disk pool=default path=/ >/dev/null 2>&1
incus profile device add claude-dev eth0 nic network=claudenet name=eth0 \
security.port_isolation=true >/dev/null 2>&1
fi
# A minimal legacy box: no template payload, just boots and networks on the
# old stack, wearing the old tag. This is what migrate has to move.
printf '\n minting a faithful legacy box on the old stack…\n'
if mint_legacy=$(incus launch images:debian/13/cloud legacybox --profile claude-dev \
--config user.claudebox=1 --vm --device root,size=20GiB 2>&1); then
wait_box legacybox && ok "legacy box up on the old stack (claudenet, user.claudebox=1)" \
|| no "legacy box never came up — cannot drill migration"
box list 2>/dev/null | grep -q '^legacybox' \
&& ok "box list shows the legacy box (dual-tag matching)" || no "legacy box invisible to 'box list'"
# Retire must REFUSE while a legacy box exists.
bash "$MIG" --retire-legacy 2>&1 | grep -qi 'legacy boxes still exist' \
&& ok "retire-legacy refuses while a legacy box remains" \
|| no "retire-legacy did NOT refuse with a legacy box present — it would strip an in-use stack"
# Re-home it.
printf ' re-homing the legacy box…\n'
bash "$MIG" --box legacybox 2>&1 | sed 's/^/ /'
[ "$(incus config get legacybox user.box 2>/dev/null)" = 1 ] \
&& ok "migrate: legacy box now tagged user.box=1" || no "migrate: user.box tag not set"
[ "$(incus config get legacybox user.box.user 2>/dev/null)" = claude ] \
&& ok "migrate: legacy box mapped to the claude user" || no "migrate: user.box.user not claude"
incus config show legacybox 2>/dev/null | grep -q '^- box-net' \
&& ok "migrate: legacy box reassigned to box-net (the new placement contract)" \
|| no "migrate: legacy box is NOT on box-net"
lip="$(boxnet_ip legacybox)"
[ -n "$lip" ] && ok "migrate: legacy box got a boxnet address ($lip) — network move landed" \
|| no "migrate: legacy box has no 10.88 address — the move did not take"
in_box legacybox getent hosts deb.debian.org >/dev/null 2>&1 \
&& ok "migrate: re-homed box resolves + reaches the internet on its new leg" \
|| no "migrate: re-homed box cannot resolve on boxnet"
# No legacy boxes remain → retire must now SUCCEED and leave nothing.
printf ' retiring the (now empty) legacy stack…\n'
bash "$MIG" --retire-legacy 2>&1 | sed 's/^/ /'
incus network show claudenet >/dev/null 2>&1 \
&& no "retire-legacy left claudenet behind" || ok "retire-legacy removed claudenet"
incus profile show claude-dev >/dev/null 2>&1 \
&& no "retire-legacy left claude-dev behind" || ok "retire-legacy removed claude-dev"
box rm legacybox --force >/dev/null 2>&1
else
no "could not launch the legacy box: $(printf '%s' "$mint_legacy" | tail -1)"
fi
fi
# ===========================================================================
if [ "$KEEP" = 1 ]; then
phase "Boxes left up (--keep-boxes)"
@ -678,7 +745,7 @@ if [ "$KEEP" = 1 ]; then
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
else
# every name the drill can have left, whatever branch a partial run took
for n in drill clone archive peer tpl; do box rm "$n" --force >/dev/null 2>&1; done
for n in drill clone archive peer tpl legacybox; do box rm "$n" --force >/dev/null 2>&1; done
# Assert OUR boxes are gone — not that the host is empty. The rm loop above
# already embodies the discipline (only names the drill minted); demanding
# 'no boxes yet' here would flag any pre-existing operator box as a failure.

137
host/migrate-host.sh Normal file
View file

@ -0,0 +1,137 @@
#!/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"
}
legacy_boxes() { incus list "user.claudebox=1" -f csv -c n 2>/dev/null; }
# 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
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