diff --git a/bin/box b/bin/box index a20f7e7..0b69d7e 100755 --- a/bin/box +++ b/bin/box @@ -77,6 +77,9 @@ CMDS=( "revoke^ [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^" "teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^" "migrate-host^--box | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^" + "versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^" + "use^^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^" + "uninstall^[] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^" "status^^^Deprecated alias for 'list'^fn:cmd_status^" "help^[]^^This help, or 'box help ' for one command^fn:cmd_help^" ) @@ -480,6 +483,56 @@ the profile, then verifies the box works on its new network leg. box migrate-host --all-boxes # re-home every legacy box box migrate-host --retire-legacy # remove the old stack (once no legacy box remains) EOF +;; + versions) cat <<'EOF' +List the versions installed under this install root — install.sh lands each +one side by side at /versions/, and a 'current' symlink tracks the +default (what the box on your PATH runs). The default is marked (current); +the tree answering THIS command is marked (running) — they differ when your +PATH resolves a different install (say, a global /opt/box shadowing yours). + + box versions + box use # switch the default + # install another version side by side: re-run install.sh +EOF +;; + use) cat <<'EOF' +Switch the default box version — repoint the 'current' symlink (and the PATH +symlink riding it) at an installed version. Refuses while ANY box exists: +never change versions under a user's boxes (#66) — 'box down' what you keep, +copy out what you need via 'box shell'/'box exec' (a portable 'box export' +is #70), 'box rm' each box, then switch. The flip is asserted afterwards: +current must resolve to the version you asked for, and the chain's +'box --version' must answer it. + + box versions # what is installed + box use 0.6.0 +EOF +;; + uninstall) cat <<'EOF' +Remove one installed version, or the whole install — the real uninstall, +replacing the old "rm -rf two paths by hand" instructions. + + box uninstall one NON-current version ('box use' another + first if you are on it) + box uninstall everything: every version, the current and + box uninstall --all PATH symlinks, and any legacy claudebox crumbs + box uninstall --purge-host run teardown-host first (all boxes, the boxnet + stack, the firewall — its own confirmation), + then remove the install + +The full uninstall runs in the safe order: boxes first — it refuses while +any exist (and names them) unless --purge-host tears them down; then the +trees and symlinks; and it ENDS with an absence assert — every removed path +is re-checked, and anything still present makes it exit 1 naming the +leftovers instead of reporting a clean uninstall that wasn't (the same +discipline as 'box revoke --purge'). Asks before removing; --force or +BOX_YES=1 skips the prompt. On a multi-user host, revoke granted users +first: 'box revoke --purge'. + + box uninstall 0.5.0 + box uninstall --all --purge-host +EOF ;; status) cat <<'EOF' Deprecated alias for 'box list'. It ignored the argument it @@ -536,8 +589,9 @@ while [ $# -gt 0 ]; do if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi # expose's own flags (--list, --remove) are positional to it, not box's if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi - # the host verbs delegate their flags to the scripts they wrap - case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke) args+=("$1"); shift; continue ;; esac + # the host verbs delegate their flags to the scripts they wrap; + # uninstall parses its own (--all, --purge-host) the same way + case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) args+=("$1"); shift; continue ;; esac if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'" fi @@ -1045,6 +1099,224 @@ cmd_migrate_host() { host_script migrate-host.sh; } cmd_grant() { host_script grant-user.sh; } cmd_revoke() { host_script revoke-user.sh; } +# --- the versioned install (#66's stance, made livable in 0.7.0) ------------ +# install.sh lands each version at /versions/, with a +# 'current' symlink naming the default and $BINDIR/box pointing through it. +# $root (readlink -f, line 8) already resolved the whole chain, so a versioned +# install always runs from .../versions/ — and a git checkout does not, +# which is how these verbs know to refuse instead of uninstalling somebody's +# working copy. +install_root() { + local vdir; vdir="$(dirname "$root")" + [ "$(basename "$vdir")" = versions ] || return 1 + dirname "$vdir" +} + +# Which boxes exist on this host, at THIS caller's tier? Prints their names +# (both tag generations) and succeeds when at least one exists; fails when +# none are visible — including when incus is absent or not answering, because +# #66's stance protects BOXES from a version change, and a daemon that cannot +# answer has none to protect. install.sh carries a byte-identical copy (it +# runs before any install tree exists); test/cli.sh diffs the two so they +# cannot drift. +existing_boxes() { + command -v incus >/dev/null 2>&1 || return 1 + { timeout 10 incus list user.box=1 --format csv --columns n /dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep . +} + +# #66, kept at flip time: never change (or remove) the version under existing +# boxes. Names every box and the remedy, then dies — a refusal that does not +# say which boxes block it sends the operator off to rediscover 'box list'. +die_under_boxes() { # $1 = the act being refused, $2 = the retry command + local names n + names="$(existing_boxes)" || return 0 + { + echo "box: this host has existing boxes:" + while IFS= read -r n; do echo "box: · $n"; done <<<"$names" + echo "box: refusing to $1 under them (#66: never change versions under a user's boxes)." + echo "box: preserve what you care about — 'box down ', copy out via 'box shell'/'box exec'" + echo "box: (a portable 'box export' is #70) — then 'box rm ' each, and re-run: $2" + } >&2 + exit 1 +} + +# The PATH symlinks that could ride this install: the one this invocation came +# in on, BOX_BIN's, and the tier default's. Candidates only — every consumer +# checks where a link actually points before touching it, so a symlink that is +# somebody else's (another install root, a hand-rolled wrapper) is never moved. +bin_links() { + local c=() + [ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}") + [ -n "${BOX_BIN:-}" ] && c+=("$BOX_BIN/box") + if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/box); else c+=("$HOME/.local/bin/box"); fi + printf '%s\n' "${c[@]}" | awk '!seen[$0]++' +} + +converge_bin_links() { # $1 = install root: point our PATH symlinks through current + local ir="$1" p t + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in + "$ir"/*) ln -sfn "$ir/current/bin/box" "$p" ;; + esac + done < <(bin_links) +} + +cmd_versions() { + local ir cur d v mark + ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to list" + cur="$(readlink -f "$ir/current" 2>/dev/null || true)" + echo "VERSIONS ($ir)" + for d in "$ir/versions"/*/; do + [ -d "$d" ] || continue + v="$(basename "$d")" + mark="" + [ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)" + [ "$(readlink -f "$d")" = "$root" ] && mark="$mark (running)" + printf ' %s%s\n' "$v" "$mark" + done + echo + echo "switch the default: box use " + echo "install another: re-run install.sh (versions land side by side)" +} + +cmd_use() { + local v="${args[0]:-}" ir eff expect out + [ -n "$v" ] || usage_error "usage: $(synopsis_of use)" + ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to switch" + [ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')" + die_under_boxes "switch the default box version" "box use $v" + ln -sfn "versions/$v" "$ir/current" + converge_bin_links "$ir" + # Assert the EFFECTIVE result, not the intent: current must resolve to the + # version asked for, and the chain's own binary must answer that version — + # a flip that "worked" while the operator's box still runs the old tree is + # exactly the flakiness this verb exists to end. + eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + [ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v" + expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)" + if [ -n "$expect" ]; then + out="$("$ir/current/bin/box" --version 2>&1 || true)" + case "$out" in + *"$expect"*) : ;; + *) die "current/bin/box answers '$out', not version $expect — the symlink chain is broken" ;; + esac + fi + echo "box: switched to $v (current -> versions/$v)" +} + +# The uninstall's own confirmation. NOT confirm() above: that one is for box +# lifecycle verbs and must never auto-accept from the environment (the drill +# exports BOX_YES=1 for the installer and still expects 'box rm' to refuse +# without --force). Uninstalling is installer-family, and BOX_YES is the +# installer-family consent contract — same as install.sh and revoke --purge. +uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY. + [ "$force" -eq 1 ] && return 0 + [ -n "${BOX_YES:-}" ] && return 0 + [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)" + local reply + printf 'box: %s? [y/N] ' "$1" + read -r reply + case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac +} + +# 'box uninstall' — the real uninstall #66 left as two rm -rf lines of prose. +# The full removal runs in the documented order: boxes first (refuse while +# they exist, or --purge-host tears the stack down with them), then trees and +# symlinks, and it ENDS by PROVING the absence — like revoke --purge, the +# last word is a re-check, not a hope. +cmd_uninstall() { + local ir a ver="" all=0 purge_host=0 cur p t granted leftover="" + local targets=() + for a in ${args[@]+"${args[@]}"}; do + case "$a" in + --all) all=1 ;; + --purge-host) purge_host=1 ;; + -*) usage_error "unknown option: $a (see 'box help uninstall')" ;; + *) [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall)"; ver="$a" ;; + esac + done + ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)" + [ -w "$ir" ] || die "cannot write $ir — a global install is uninstalled as root: sudo box uninstall" + + # -- one version ----------------------------------------------------------- + if [ -n "$ver" ] && [ "$all" -eq 0 ]; then + [ "$purge_host" -eq 0 ] || usage_error "--purge-host goes with the full uninstall, not a single version" + [ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')" + cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + [ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'box use ' first, or 'box uninstall --all' for everything" + uninstall_confirm "remove box version $ver from $ir" + # rm's exit code is not the verdict — the absence re-check below is (a + # half-removed tree must be reported as INCOMPLETE, not as a crash). + rm -rf "$ir/versions/$ver" || true + if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then + echo "box: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2 + exit 1 + fi + echo "box: removed version $ver (the default stays $cur)" + return 0 + fi + [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall) — a version and --all together is ambiguous" + + # -- everything ------------------------------------------------------------ + if [ "$purge_host" -eq 1 ]; then + # Granted users' worlds are not ours to erase silently — name them first; + # 'box revoke --purge' is the clean path (and asserts its absence). + granted="$(timeout 10 incus project list --format csv 2>/dev/null --purge' removes each world cleanly first" >&2 + bash "$root/host/teardown-host.sh" \ + || die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)" + else + die_under_boxes "uninstall box" "box uninstall (or 'box uninstall --purge-host' to tear the host stack down with them)" + fi + uninstall_confirm "remove the ENTIRE box install at $ir (every version)" + + # The removal set, gathered BEFORE anything is deleted, so the absence + # assert below re-checks exactly what was promised gone. PATH symlinks are + # removed only when they resolve into (or dangle at) THIS install root. + targets+=("$ir") + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in "$ir"/*) targets+=("$p") ;; esac + done < <(bin_links) + # Legacy crumbs: the pre-0.4.0 command name and the pre-0.5.0 tree. A real + # uninstall leaves neither generation behind. + while IFS= read -r p; do + p="$(dirname "$p")/claudebox" + [ -L "$p" ] && targets+=("$p") + done < <(bin_links) + [ -d "$HOME/.local/share/claudebox" ] && targets+=("$HOME/.local/share/claudebox") + mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++') + + # rm's exit code is not the verdict — the absence assert below is (a + # half-removed tree must be reported as INCOMPLETE by name, not as a crash). + for p in "${targets[@]}"; do rm -rf "$p" || true; done + + # END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink. + # A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims + # get verified (the revoke --purge discipline). + for p in "${targets[@]}"; do + if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi + done + if [ -n "$leftover" ]; then + echo "box: uninstall INCOMPLETE — still present:$leftover" >&2 + echo "box: remove them by hand, and re-check each path is really gone." >&2 + exit 1 + fi + echo "box: uninstalled — removed:" + for p in "${targets[@]}"; do echo "box: · $p"; done + if [ "$purge_host" -eq 0 ]; then + echo "box: note — the host stack (boxnet, firewall), if this host has one, was NOT touched:" + echo "box: run teardown-host from a checkout (host/teardown-host.sh), or use --purge-host next time." + fi +} + cmd_help() { show_help "${args[0]:-}"; } # The escape hatch. The box is resolved and tag-checked; everything else is diff --git a/drill/drill.sh b/drill/drill.sh index 9bb7dbe..257e715 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -202,10 +202,11 @@ EOF export BOX_SKIP_SETUP_HOST=1 fi - # The installer is a no-op when box is already installed — upgrading is - # uninstall-then-install, by design. The drill re-proves a tree from scratch - # every run, so it does the uninstall itself: clear any prior tree and symlink - # before installing, or install.sh would correctly refuse to touch them. + # The installer converges when a version is already installed (0.7.0's + # versioned layout: re-running the same version is a no-op, and an upgrade + # lands side-by-side without flipping under boxes). The drill re-proves a + # tree from SCRATCH every run — a fresh host, not a converged one — so it + # removes the whole install root and symlink first. rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box" # The installer prompts (install? set up host?) and reads /dev/tty. The drill @@ -224,7 +225,7 @@ EOF # reads BOX_* — the vars were ignored, main was installed, and the run drilled # the wrong tree while reporting success. A drill that silently drills the # wrong code is worse than one that fails. - got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '')" + got="$(cat "$HOME/.local/share/box/current/INSTALLED_FROM" 2>/dev/null || echo '')" if [ "$got" != "$REPO@$REF" ]; then echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2 echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2 @@ -302,7 +303,7 @@ if [ "${DRILL_OWNS_SETUP:-0}" != 1 ]; then echo " install.sh is supposed to run the host setup itself (#64), and setup-host" >&2 echo " is supposed to converge in one run (#63). One of those did not happen." >&2 echo " reproduce with the output visible:" >&2 - echo " ~/.local/share/box/host/setup-host.sh" >&2 + echo " ~/.local/share/box/current/host/setup-host.sh" >&2 echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2 exit 1 fi @@ -361,7 +362,7 @@ left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')" # proves is idempotency: a second run over a cleaned host is a no-op that # restores the stack rather than a fresh build. inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…" -if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then +if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then echo "drill: setup-host.sh failed or timed out (>5 min)." >&2 echo " it should take seconds on a host that already has incus. usual causes:" >&2 echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2 @@ -469,7 +470,7 @@ phase "B. The box surface" # =========================================================================== # Compare against the installed tree's VERSION file, not a hardcoded number — # a pinned literal here would fail the drill on every release. -expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')" +expected="$(cat "$HOME/.local/share/box/current/VERSION" 2>/dev/null || echo '?')" v="$(box --version 2>&1)" case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac @@ -498,7 +499,7 @@ box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \ # The one rule that keeps templates honest: no key can name a network. Plant a # bad template in the installed tree (the drill owns this host), expect the # parser to reject it BY NAME, remove it. -badt="$HOME/.local/share/box/templates/cbdrill-bad" +badt="$HOME/.local/share/box/current/templates/cbdrill-bad" mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml" box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \ && ok "a template cannot name a network — BOX_NETWORK rejected by name" \ @@ -889,7 +890,7 @@ phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)" # 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/box/host/migrate-host.sh" +MIG="$HOME/.local/share/box/current/host/migrate-host.sh" if [ ! -f "$MIG" ]; then no "migrate-host.sh not installed — cannot drill the transition" else @@ -985,5 +986,5 @@ fi echo inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules" inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)." -inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]" +inf "to undo: box uninstall --purge-host (or ~/.local/share/box/current/host/teardown-host.sh)" [ "$fail" -eq 0 ] diff --git a/host/teardown-host.sh b/host/teardown-host.sh index af6266f..4a3d5d3 100755 --- a/host/teardown-host.sh +++ b/host/teardown-host.sh @@ -3,20 +3,34 @@ # ancestor created, so one teardown cleans a host of any generation: all boxes # (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev # profiles, and both generations of firewall units and nft tables. -# Usage: ./host/teardown-host.sh [--purge-incus] +# Usage: ./host/teardown-host.sh [--purge-incus] [--yes] # --purge-incus also apt-purge Incus itself (skipped if non-box # instances still exist on this host) +# --yes skip the confirmation (BOX_YES=1 does the same) — for +# automation: CI's uninstall drill and 'box uninstall +# --purge-host' run this unattended set -euo pipefail -purge=false -[ "${1:-}" = "--purge-incus" ] && purge=true +purge=false; yes=0 +for arg in "$@"; do + case "$arg" in + --purge-incus) purge=true ;; + --yes|-y) yes=1 ;; + *) echo "teardown-host: unknown option: $arg" >&2; exit 2 ;; + esac +done +[ -n "${BOX_YES:-}" ] && yes=1 echo "This removes ALL boxes (uncommitted work in them is lost), the" echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules" echo "(both current and pre-0.4.0 names)." $purge && echo "Incus itself will also be uninstalled (--purge-incus)." -read -rp "Continue? [y/N] " a -case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac +if [ "$yes" -eq 1 ]; then + echo "(confirmed non-interactively: --yes/BOX_YES)" +else + read -rp "Continue? [y/N] " a + case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac +fi # Instances — both tag generations, one delete at a time (a multi-name # 'incus delete' aborts at the first missing name). @@ -72,4 +86,4 @@ if $purge; then fi fi -echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)" +echo "Teardown complete. (The box install tree itself remains — 'box uninstall' removes it, with a zero-residue check.)"