diff --git a/drill/drill.sh b/drill/drill.sh index cf8961b..17680cb 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -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, codex, grok - · 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, codex, grok, 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 codex grok; do +for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl codex grok 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 @@ -697,6 +697,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)" @@ -704,7 +771,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 codex grok; do box rm "$n" --force >/dev/null 2>&1; done + for n in drill clone archive peer tpl codex grok 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. diff --git a/host/migrate-host.sh b/host/migrate-host.sh new file mode 100644 index 0000000..c4b2968 --- /dev/null +++ b/host/migrate-host.sh @@ -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 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 | --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 \ + | awk '{for(i=1;i/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