From aad576a86a20e4adcdf89fdaa9ed5c71969c9edc Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 17 Jul 2026 12:52:50 +0000 Subject: [PATCH 1/5] Make host setup complete in one run, and let the installer run it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit box setup-host stopped halfway when it had to add you to incus-admin: it usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 — a success-shaped no-op with no boxnet, no ACL, no box-net profile and no firewall behind it. It now re-execs itself under 'sg incus-admin' and finishes in that same invocation. The membership check was also asking the wrong question. 'id -nG "$USER"' names a user, so it reads the group database — which lists incus-admin the instant usermod returns, while the shell's own credentials still lack it (supplementary groups are fixed at login). A same-session re-run therefore passed the check and died further down on a bare permission error from incus that mentioned neither the group nor the re-login. Argless 'id -nG' asks the process what it actually holds, which is what incus checks when it opens /var/lib/incus/unix.socket. With one run now sufficient, install.sh runs the setup itself instead of printing a warning and leaving the user a command: the install reported success and 'box new' then failed on a host with no Incus. setup-host is idempotent, so doing this on every install is also how an upgraded host picks up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves the install standing and says what to re-run. Fixes #63 Fixes #64 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 24 ++++++++++++++++++++++++ README.md | 11 ++++++++--- bin/box | 7 ++++--- host/setup-host.sh | 30 ++++++++++++++++++++++++++---- install.sh | 42 +++++++++++++++++++++++++++++++++++------- 5 files changed, 97 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8715206..8af9c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md), which records not just what changed but what each drill run proved. +## Unreleased + +### Fixed + +- **`box setup-host` finishes in one run** (#63). When it had to add you to + `incus-admin` it stopped there and told you to re-login and re-run — an + `exit 0` that reported success having built none of the stack: no `boxnet`, + no ACL, no `box-net` profile, no firewall. It now re-execs itself under + `sg incus-admin` and completes in that one invocation. The membership check + was also asking the wrong question: `id -nG "$USER"` reads the group + database, which lists the group the moment `usermod` returns, so a + same-session re-run passed the check with credentials that still lacked the + group and died further down on a bare permission error from `incus`. Argless + `id -nG` asks the process what it actually holds. + +### Changed + +- **`install.sh` runs the host setup itself** (#64) — it printed a warning and + left you a command to run, so the install reported success and `box new` + failed on a host with no Incus. Since `setup-host` is idempotent, doing this + on every install is also how an upgraded host picks up stack changes. + `BOX_SKIP_SETUP_HOST=1` opts out; if setup fails, the install still stands + and says what to re-run. + ## 0.5.0 — 2026-07-15 The release the project was renamed in: the repo is `heavy-duty/box`, matching diff --git a/README.md b/README.md index 94aa408..8fbe035 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,18 @@ curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | ba ``` Installs the tree to `~/.local/share/box` and links `box` onto your -`PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also -retires the old `claudebox` symlink. (No `git clone` needed.) +`PATH`, then runs the host setup below for you (it may ask for `sudo`; set +`BOX_SKIP_SETUP_HOST=1` to opt out and run it yourself). Re-run any time to +upgrade — upgrading re-applies the host stack, and from a pre-0.4.0 install +also retires the old `claudebox` symlink. (No `git clone` needed.) ## One-time host setup (Ubuntu 24.04 / Debian 13) +The installer already does this. Run it directly to set up a host you +installed with `BOX_SKIP_SETUP_HOST=1`, or to re-apply the stack by hand: + ```sh -box setup-host # run twice if it adds you to incus-admin (re-login between) +box setup-host # one run is enough ``` Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT diff --git a/bin/box b/bin/box index 63be4de..c04f0e0 100755 --- a/bin/box +++ b/bin/box @@ -387,10 +387,11 @@ EOF Prepare this host to mint boxes — one time. Installs Incus and builds the isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate ACL, the box-net profile, and the firewall rules, all re-applied at boot. -Idempotent — safe to re-run after a box upgrade to pick up stack changes. +Idempotent — safe to re-run after a box upgrade to pick up stack changes; +install.sh runs it for you, so this is for re-applying by hand. -If it has to add you to the incus-admin group it will say so and exit; log -back in (or 'sg incus-admin') and run it again. +One run is enough. If it has to add you to the incus-admin group it re-runs +itself under that group — no re-login, no second invocation. box setup-host EOF diff --git a/host/setup-host.sh b/host/setup-host.sh index 3e28d43..29964c9 100755 --- a/host/setup-host.sh +++ b/host/setup-host.sh @@ -3,17 +3,39 @@ # the box-net profile. Idempotent. Ubuntu 24.04 / Debian 13. set -euo pipefail -here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +here="$(dirname "$(dirname "$self")")" if ! command -v incus >/dev/null; then sudo apt-get update sudo apt-get install -y incus fi -if ! id -nG "$USER" | grep -qw incus-admin; then +# Group membership is a property of THIS PROCESS's credentials, not of the group +# database — and the two disagree for exactly as long as it matters here. +# 'id -nG "$USER"' names a user, so it reads /etc/group and reports incus-admin +# the instant usermod returns; the running shell's own credentials still lack +# it, because supplementary groups are fixed at login. So the old check passed +# on a same-session re-run, sailed into the incus calls below, and died on a +# permission error that named neither the group nor the re-login. Argless +# 'id -nG' asks the process what it actually holds, which is what incus checks +# when it opens /var/lib/incus/unix.socket. +if ! id -nG | grep -qw incus-admin; then sudo usermod -aG incus-admin "$USER" - echo "NOTE: added $USER to incus-admin — re-login (or 'sg incus-admin') and re-run." - exit 0 + # Then finish the job rather than adjourning it. Exiting 0 here was a + # success-shaped no-op: no boxnet, no ACL, no box-net profile, no firewall — + # and the burden of knowing that on the reader of a NOTE (#63). 'sg' runs us + # again with the new group in our credentials, no re-login, one invocation. + # The guard makes that at most one hop: if sg somehow lands without the + # group, we fail loudly instead of forking forever. + if [ -z "${BOX_SETUP_HOST_REEXEC:-}" ]; then + echo "added $USER to incus-admin — re-running under the new group (no re-login needed)" + export BOX_SETUP_HOST_REEXEC=1 + exec sg incus-admin -c "$(printf '%q ' bash "$self" "$@")" + fi + echo "ERROR: still not in incus-admin after usermod + sg." >&2 + echo " log out and back in, then re-run: box setup-host" >&2 + exit 1 fi # Storage pool + base config (safe to re-run: skipped once the pool exists). diff --git a/install.sh b/install.sh index 1d269e0..fae5cc6 100755 --- a/install.sh +++ b/install.sh @@ -84,16 +84,44 @@ case ":$PATH:" in ;; esac -# --- environment check ----------------------------------------------------- -if ! command -v incus >/dev/null 2>&1; then - warn "incus was not found. box needs Incus on the host." - warn " run the one-time host setup: $DEST/host/setup-host.sh" -fi - # Record WHAT was installed, so a caller can assert it got what it asked for. # Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names # retired in 0.5.0) silently falls back to the defaults and installs main — # and the caller drills the wrong tree, believing it drilled its branch. +# Written BEFORE host setup: this records the install, which has now happened, +# and it must not hinge on whether the host stack came up. printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM" -log "done ($REPO@$REF) — try: box new --name test" +# --- host setup ------------------------------------------------------------ +# The installer finishes the job (#64). Telling the user to go run setup-host +# was a step that read as optional and failed later as mysterious: the install +# reports success, 'box' is on PATH, and 'box new' dies on a host with no +# Incus, no boxnet, no profile. setup-host is idempotent by design, so doing +# this on EVERY install is also how an upgraded host picks up stack changes — +# the isolation fixes that ship as new firewall rules land when the tool that +# claims them lands, instead of waiting on someone to re-run a command. +# BOX_SKIP_SETUP_HOST=1 opts out: CI, image builds, a host set up by hand. +setup_ok="" +if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then + log "skipping host setup (BOX_SKIP_SETUP_HOST is set) — run it yourself: box setup-host" +elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then + warn "host setup needs root and sudo was not found." + warn " run this as root to finish: $DEST/host/setup-host.sh" +else + log "running one-time host setup (installs Incus + the isolation stack; may ask for sudo)" + # Date: Fri, 17 Jul 2026 13:16:06 +0000 Subject: [PATCH 2/5] Make setup-host privilege-aware; make the drill prove the new contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two real problems, both confirmed by reproducing them. setup-host hardcoded 'sudo' for every privileged call, so install.sh's deliberate root branch — the one that proceeds when id -u is 0 even with no sudo installed — handed off to a script that died on 'sudo: command not found' before doing anything (exit 127, reproduced with env -i and a minimal PATH). The root path was nominal, not real. Privilege is now resolved once: nothing at UID 0, sudo otherwise, a clear error if neither is possible. Two things fell out of that. Root does not need incus-admin at all (UID 0 opens the socket regardless), so adding root to the group was a no-op that also missed the human — under 'sudo install.sh' that is SUDO_USER, who is now the one granted the group. And apt must not hang: install.sh runs setup-host with nobody watching, while a fresh cloud image holds the dpkg lock in apt-daily for its first minutes, so the calls are now bounded and non-interactive. The drill did not exercise any of this. It ran setup-host immediately after install.sh, so the stack existed by the drill's own hand and a run passed identically whether or not install.sh had done a thing — a fresh run converged three times while its messages still described the pre-#63 "first pass may only add you to the group" behaviour. It now asserts the post-install stack in-group, before the clean or anything else mutates the host, which is the assertion that actually proves #64. setup-host then runs exactly once more, after the clean — that one is load-bearing, since the clean deliberately unsets dns.mode and something has to converge it back. DRILL_OWNS_SETUP=1 hands sequencing back to the drill. Pre-setup tripwires now read before install.sh, because install.sh is what triggers setup now; read afterwards they said nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++++++++++ drill/drill.sh | 98 ++++++++++++++++++++++++++++++++++++++++------ host/setup-host.sh | 68 ++++++++++++++++++++++++++------ 3 files changed, 166 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af9c21..18a5a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,34 @@ which records not just what changed but what each drill run proved. group and died further down on a bare permission error from `incus`. Argless `id -nG` asks the process what it actually holds. +- **`setup-host` works as root, with or without `sudo`** — every privileged + call was a hardcoded `sudo`, so on a minimal root image (no `sudo` package) + it died on `sudo: command not found` before doing anything. Privilege is now + resolved once: nothing at UID 0, `sudo` otherwise, and a clear error if + neither is possible. This is what made `install.sh`'s root path real rather + than nominal. +- **`setup-host` grants `incus-admin` to the human, not to root** — under + `sudo install.sh` it would have added `root` to the group: a no-op (UID 0 + opens the socket regardless) that also left the actual user locked out of + their own boxes. It now derives the login user from `SUDO_USER`. +- **`setup-host`'s apt calls can no longer hang** — a fresh cloud image has + `apt-daily`/`unattended-upgrades` holding the dpkg lock, and a plain + `apt-get install` waits on it silently and indefinitely. Now bounded + (`DPkg::Lock::Timeout=300`) and non-interactive, which matters because + `install.sh` runs it with nobody watching. + ### Changed +- **`drill.sh` proves the new contract instead of masking it** — the drill ran + `setup-host` itself right after installing, so the stack existed by its own + hand and a run passed identically whether or not `install.sh` had done a + thing; a fresh run converged the stack three times, while the messages still + described the pre-#63 "first pass may only add you to the group" behaviour. + It now asserts the post-install stack in-group before touching the host, and + runs `setup-host` exactly once more — after the clean, which deliberately + unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1` + hands sequencing back to the drill. Pre-setup tripwires now read *before* + `install.sh`, since that is what triggers setup now. - **`install.sh` runs the host setup itself** (#64) — it printed a warning and left you a command to run, so the install reported success and `box new` failed on a host with no Incus. Since `setup-host` is idempotent, doing this diff --git a/drill/drill.sh b/drill/drill.sh index f638b3a..38e2d94 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -172,6 +172,36 @@ EOF fi phase "Installing box ($REPO@$REF)" + + # Sudo, up front and out loud. Later calls run unattended, and a password + # prompt swallowed by a '-qq' redirect looks exactly like a hang. This now + # has to precede the install too: install.sh runs the host setup itself, so + # the first thing needing root is no longer further down — it is inside the + # very next command. + sudo -v || { echo "drill: need sudo (the host setup installs packages and firewall rules)"; exit 1; } + + # Pre-setup observations must be READ BEFORE install.sh, because install.sh + # is now what runs setup-host. Read after it and setup has already had its + # chance to act, so the observation says nothing. + # setup-host.sh installs nftables itself when neither nft nor UFW exists + # (a stock Debian 13 cloud image ships neither). This is a tripwire: if it + # fires, that fix regressed. + fw_absent_pre=0 + if ! command -v nft >/dev/null 2>&1 && ! command -v ufw >/dev/null 2>&1; then + fw_absent_pre=1 + fi + + # DRILL_OWNS_SETUP=1 opts out of the installer's automatic setup and puts the + # drill back in charge of sequencing it (install, then clean, then converge). + # The DEFAULT deliberately does not: a drill that runs setup-host itself right + # after installing cannot tell you whether install.sh did its job, because the + # drill's own call would build the stack either way. The default path exercises + # what a user actually runs, and asserts the result in-group below. + OWNS="${DRILL_OWNS_SETUP:-0}" + if [ "$OWNS" = 1 ]; then + export BOX_SKIP_SETUP_HOST=1 + fi + BOX_REPO="$REPO" BOX_REF="$REF" \ bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \ || { echo "install failed"; exit 1; } @@ -195,17 +225,10 @@ EOF inf "installed tree confirms: $got" phase "Host setup (Incus, boxnet, ACL, profile, firewall)" - # setup-host.sh installs nftables itself when neither nft nor UFW exists - # (a stock Debian 13 cloud image ships neither). This guard is a tripwire: - # if it fires, that fix regressed. - if ! command -v nft >/dev/null 2>&1 && ! command -v ufw >/dev/null 2>&1; then + if [ "$fw_absent_pre" = 1 ]; then note "neither nft nor ufw present pre-setup — setup-host.sh must install nftables itself (it fixed this once; watch that it still does)" fi - # Sudo, up front and out loud. Later calls run unattended, and a password - # prompt swallowed by a '-qq' redirect looks exactly like a hang. - sudo -v || { echo "drill: need sudo (the host setup installs packages and firewall rules)"; exit 1; } - # apt's lock is held by apt-daily / unattended-upgrades on a fresh cloud # image, and 'apt-get -qq >/dev/null' waits for it in COMPLETE SILENCE — # which is how run 5 looked stuck for minutes right after this header. @@ -224,16 +247,57 @@ EOF inf "incus already installed — skipping apt" fi - inf "running setup-host.sh (first pass: may only add you to incus-admin)…" - ~/.local/share/box/host/setup-host.sh || true - # The group we were just added to isn't in this shell's credentials yet. + # No setup-host call here any more. It used to run a full "first pass" that + # the comment described as "may only add you to incus-admin" — behaviour that + # no longer exists (setup-host converges in one run now, #63) and that, since + # install.sh runs setup itself (#64), was simply the stack being built a + # second time before the drill had asserted the first. + if [ "$OWNS" = 1 ]; then + # We opted out of the installer's setup, so nobody has joined us to the + # group yet. usermod ONLY: the stack build waits until after the clean + # below, which is the entire reason for owning the sequence. + inf "DRILL_OWNS_SETUP=1 — the drill owns the host setup" + id -nG | grep -qw incus-admin || sudo usermod -aG incus-admin "$USER" + else + inf "install.sh ran the host setup — asserting what it left, in-group, next" + fi + # setup-host's own sg re-exec was a CHILD of install.sh; this shell's + # credentials are untouched, so we still have to enter the group ourselves — + # once, for the remainder of the drill. inf "re-entering inside the incus-admin group…" - exec sg incus-admin -c "IN_GROUP=1 BOX_REPO='$REPO' BOX_REF='$REF' KEEP=$KEEP bash '$SELF' --in-group" + exec sg incus-admin -c "IN_GROUP=1 DRILL_OWNS_SETUP='$OWNS' BOX_REPO='$REPO' BOX_REF='$REF' KEEP=$KEEP bash '$SELF' --in-group" fi export PATH="$HOME/.local/bin:$PATH" KEEP="${KEEP:-0}" +# PROVE THE INSTALLER'S CONTRACT (#64) — first, before the clean or anything +# else on this host mutates the stack, and before the drill runs setup-host +# itself further down. That ordering is the whole point: the old flow ran +# setup-host immediately after installing, so the stack existed by the drill's +# own hand and the run passed identically whether or not install.sh had done a +# thing. This is read-only, so it is safe with a previous run's boxes still +# attached. +if [ "${DRILL_OWNS_SETUP:-0}" != 1 ]; then + phase "Asserting the stack that install.sh built" + missing="" + incus network show boxnet >/dev/null 2>&1 || missing="$missing boxnet" + incus network acl show box-isolate >/dev/null 2>&1 || missing="$missing box-isolate" + incus profile show box-net >/dev/null 2>&1 || missing="$missing box-net" + # Last thing setup-host does, so it doubles as "it ran to the end". + sudo nft list table bridge box >/dev/null 2>&1 || missing="$missing nft-bridge-box" + if [ -n "$missing" ]; then + echo "drill: FATAL — install.sh reported success but left an INCOMPLETE stack:$missing" >&2 + 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 " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2 + exit 1 + fi + ok "install.sh left a complete host stack (boxnet, box-isolate, box-net, nft bridge drop) — no second setup needed" +fi + # CLEAN BEFORE SETUP, not after. setup-host.sh reconfigures the network's ACLs, # and a previous run's boxes are still ATTACHED to that network — 'incus network # set' then has to push the change onto every live NIC, which is how run 6 @@ -277,7 +341,15 @@ done left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')" [ -n "$left" ] && inf "instances still on this host (not ours, left alone): $left" -inf "running setup-host.sh (in-group pass: network, ACL, profile, firewall)…" +# This call stays, and it is NOT the install's setup repeated for its own sake: +# the clean above deliberately unsets dns.mode, which is part of the SHIPPED +# stack, and drops a previous run's phase-D mutations. Something has to put the +# host back together afterwards, and setup-host is that something — this is the +# "converge against a clean slate" the block above is ordered for. On the +# default path the install's setup has already been asserted, so what this +# 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 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 diff --git a/host/setup-host.sh b/host/setup-host.sh index 29964c9..8cfe82e 100755 --- a/host/setup-host.sh +++ b/host/setup-host.sh @@ -6,11 +6,53 @@ set -euo pipefail self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" here="$(dirname "$(dirname "$self")")" -if ! command -v incus >/dev/null; then - sudo apt-get update - sudo apt-get install -y incus +# How we reach root, decided once. 'sudo' cannot be hardcoded: at UID 0 it is +# unnecessary, and on a minimal root image it is not installed at all — this +# script died on 'sudo: command not found' before doing anything, which made +# install.sh's deliberate root path unusable on exactly the hosts it was for. +if [ "$(id -u)" -eq 0 ]; then + SUDO="" +elif command -v sudo >/dev/null 2>&1; then + SUDO="sudo" +else + echo "ERROR: host setup needs root and 'sudo' was not found." >&2 + echo " re-run this as root: $self" >&2 + exit 1 fi +# apt, unattended-safe. install.sh now runs us without a human watching, and +# a fresh cloud image has apt-daily/unattended-upgrades holding the dpkg lock +# for the first minutes of its life — plain 'apt-get install' then waits on it +# in complete silence, indefinitely. Bound the wait and never prompt. +# 'env', not a bare VAR=val prefix: bash recognises assignments at PARSE time, +# so with $SUDO empty (we are root) 'DEBIAN_FRONTEND=x apt-get' would have +# already been parsed as a plain word and bash would try to EXECUTE it — +# 'DEBIAN_FRONTEND=noninteractive: command not found'. env is immune. +apt_get() { + $SUDO env DEBIAN_FRONTEND=noninteractive apt-get -o DPkg::Lock::Timeout=300 "$@" +} + +if ! command -v incus >/dev/null; then + apt_get update + apt_get install -y incus +fi + +if [ "$(id -u)" -eq 0 ]; then + # Root needs no group: UID 0 opens /var/lib/incus/unix.socket regardless of + # who owns it, and there is nothing to re-exec into. The HUMAN needs it — and + # under 'sudo install.sh' that is SUDO_USER, not the root we are running as. + # Adding root to incus-admin would be a no-op that also left the actual user + # locked out of their own boxes. + # NOTE: 'id -nG "$name"' here is deliberate and NOT the bug fixed below. That + # bug was asking the DATABASE about our own process; this asks the database + # about someone else's account, which is the only thing it can be asked. + login_user="${SUDO_USER:-}" + if [ -n "$login_user" ] && [ "$login_user" != root ]; then + if ! id -nG "$login_user" | grep -qw incus-admin; then + usermod -aG incus-admin "$login_user" + echo "added $login_user to incus-admin — log out and back in for your shell to pick it up" + fi + fi # Group membership is a property of THIS PROCESS's credentials, not of the group # database — and the two disagree for exactly as long as it matters here. # 'id -nG "$USER"' names a user, so it reads /etc/group and reports incus-admin @@ -20,8 +62,8 @@ fi # permission error that named neither the group nor the re-login. Argless # 'id -nG' asks the process what it actually holds, which is what incus checks # when it opens /var/lib/incus/unix.socket. -if ! id -nG | grep -qw incus-admin; then - sudo usermod -aG incus-admin "$USER" +elif ! id -nG | grep -qw incus-admin; then + $SUDO usermod -aG incus-admin "$USER" # Then finish the job rather than adjourning it. Exiting 0 here was a # success-shaped no-op: no boxnet, no ACL, no box-net profile, no firewall — # and the burden of knowing that on the reader of a NOTE (#63). 'sg' runs us @@ -50,7 +92,7 @@ fi # says so. if ! incus storage show default >/dev/null 2>&1; then driver=btrfs - command -v mkfs.btrfs >/dev/null 2>&1 || sudo apt-get install -y btrfs-progs || driver=dir + command -v mkfs.btrfs >/dev/null 2>&1 || apt_get install -y btrfs-progs || driver=dir if ! incus admin init --preseed </dev/null 2>&1 && ! command -v nft >/dev/null 2>&1; then - sudo apt-get install -y nftables + apt_get install -y nftables fi -sudo install -m 755 "$here/host/box-firewall.sh" /usr/local/sbin/box-firewall -sudo install -m 644 "$here/host/box-firewall.service" /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable box-firewall.service +$SUDO install -m 755 "$here/host/box-firewall.sh" /usr/local/sbin/box-firewall +$SUDO install -m 644 "$here/host/box-firewall.service" /etc/systemd/system/ +$SUDO systemctl daemon-reload +$SUDO systemctl enable box-firewall.service # RESTART, not 'enable --now'. The unit is RemainAfterExit, so once it has run # it stays "active" forever — and 'enable --now' does nothing to an active unit. # Re-running setup-host after upgrading the tool therefore installed the new # rules to /usr/local/sbin and never applied them: the host kept the old # firewall, silently, and the box→box hole stayed open through a release that # claimed to close it. Restart re-runs the script, which is idempotent by design. -sudo systemctl restart box-firewall.service +$SUDO systemctl restart box-firewall.service # Profile — box-net, the placement contract: the isolated NIC and the root # disk, nothing a template controls (resources are stamped per-instance from @@ -165,7 +207,7 @@ incus profile edit box-net < "$here/profiles/box-net.yaml" # The sibling drop is the one rule whose absence is invisible: everything keeps # working, and boxes can simply reach each other. Assert it landed. -if sudo nft list table bridge box >/dev/null 2>&1; then +if $SUDO nft list table bridge box >/dev/null 2>&1; then echo "Isolation: box-to-box drop is live (nft bridge table 'box')." else echo "WARNING: the box-to-box drop is NOT active — boxes can reach each other." >&2 -- 2.45.2 From 3e1143f1d3a83925f1010bd24653203ed6f7bc8b Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 17 Jul 2026 13:34:53 +0000 Subject: [PATCH 3/5] Refuse to change versions under existing boxes; file the migration as #67 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @danmt on #66: hatch first, the version-aware migration as its own issue. Building the host stack from the installer means an upgrade is no longer a tree swap — it reaches under every box attached to that stack. So the installer now declines to guess. Same version and ref: it says so and changes nothing. Version or ref change with boxes on the host: it refuses, lists them, and does so BEFORE $DEST is touched, so a refusal leaves the working install intact. No boxes: nothing to lose, proceed. BOX_FORCE_UPGRADE=1 overrides, and the drill sets it, because arriving on a dirty host and wiping it is the drill's job. Ref, not just VERSION: a branch and main carry the same VERSION string, so VERSION alone would call an install of this very branch "unchanged" and skip the hatch. Both tag generations count as boxes — a pre-rename user.claudebox=1 box is just as much someone's work as a current one. The box query runs unprivileged first and escalates only if the socket refuses: anyone who owns boxes is already in incus-admin, and an installer should not demand a sudo password merely to look. The error deliberately does not suggest snapshot -> rm -> restore --from: 'box rm' deletes a box AND every snapshot it has, so that path loses the data at the rm. It says to copy anything needed out of the box first. Raised on #67. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 ++++++++--- README.md | 15 ++++++++--- drill/drill.sh | 6 +++++ install.sh | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a5a7d..884c6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,10 +48,17 @@ which records not just what changed but what each drill run proved. `install.sh`, since that is what triggers setup now. - **`install.sh` runs the host setup itself** (#64) — it printed a warning and left you a command to run, so the install reported success and `box new` - failed on a host with no Incus. Since `setup-host` is idempotent, doing this - on every install is also how an upgraded host picks up stack changes. - `BOX_SKIP_SETUP_HOST=1` opts out; if setup fails, the install still stands - and says what to re-run. + failed on a host with no Incus. `BOX_SKIP_SETUP_HOST=1` opts out; if setup + fails, the install still stands and says what to re-run. +- **`install.sh` refuses to change versions under existing boxes** — building + the host stack from the installer means an upgrade reaches under every box + attached to that stack, so it is no longer only a tree swap. Same version and + ref: says so, changes nothing. Version or ref change with boxes on the host + (either tag generation): refuses, loudly, listing them — and refuses *before* + `$DEST` is touched, so the working install survives the refusal. No boxes: + proceeds. `BOX_FORCE_UPGRADE=1` overrides; the drill sets it, since wiping + boxes is its job. The version-aware upgrade that migrates instead of refusing + is #67. ## 0.5.0 — 2026-07-15 diff --git a/README.md b/README.md index 8fbe035..8d422de 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,18 @@ curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | ba Installs the tree to `~/.local/share/box` and links `box` onto your `PATH`, then runs the host setup below for you (it may ask for `sudo`; set -`BOX_SKIP_SETUP_HOST=1` to opt out and run it yourself). Re-run any time to -upgrade — upgrading re-applies the host stack, and from a pre-0.4.0 install -also retires the old `claudebox` symlink. (No `git clone` needed.) +`BOX_SKIP_SETUP_HOST=1` to opt out and run it yourself). Upgrading from a +pre-0.4.0 install also retires the old `claudebox` symlink. (No `git clone` +needed.) + +**Upgrading, and boxes.** Because the installer builds the host stack, an +upgrade reaches under the boxes attached to it — so it is deliberate about it. +Re-running the same version changes nothing and says so. Changing version on a +host that has boxes **refuses**, lists them, and leaves your install exactly as +it was; deal with the boxes and re-run, or say `BOX_FORCE_UPGRADE=1` to upgrade +over them on purpose (boxes are not deleted, but the stack is rebuilt beneath +them). With no boxes on the host, it just upgrades. Migrating boxes across an +upgrade instead of refusing is [#67](https://github.com/heavy-duty/box/issues/67). ## One-time host setup (Ubuntu 24.04 / Debian 13) diff --git a/drill/drill.sh b/drill/drill.sh index 38e2d94..5de6e8a 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -202,6 +202,12 @@ EOF export BOX_SKIP_SETUP_HOST=1 fi + # The installer refuses to change versions on a host that still has boxes. + # That hatch is for humans with work to lose; the drill's whole job is to + # arrive on a dirty host, wipe every box it recognises (below) and re-prove + # the stack from there — so it opts out, deliberately and in one place. + export BOX_FORCE_UPGRADE=1 + BOX_REPO="$REPO" BOX_REF="$REF" \ bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \ || { echo "install failed"; exit 1; } diff --git a/install.sh b/install.sh index fae5cc6..fbc1170 100755 --- a/install.sh +++ b/install.sh @@ -47,6 +47,77 @@ EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" [ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive" [ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?" +# --- upgrade hatch --------------------------------------------------------- +# This installer builds the host stack itself now, and every box on the host is +# attached to that stack — so a version change here is not just a tree swap, it +# reaches under running boxes. Until the version-aware migration exists (#67), +# refuse rather than guess: if this would change what is installed AND there are +# boxes on the host, stop and let a human decide. Checked BEFORE $DEST is +# touched, so a refusal leaves the working install exactly as it was. +# Same version + same ref = nothing to change: say so and carry on. +new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || echo unknown)" +old_ver="$(cat "$DEST/VERSION" 2>/dev/null || true)" +old_from="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || true)" + +if [ -n "$old_ver" ] && [ "$old_ver" = "$new_ver" ] && [ "$old_from" = "$REPO@$REF" ]; then + CHANGING=0 +else + CHANGING=1 +fi + +# How we ask incus about boxes. No incus => no boxes, and nothing to protect. +if [ "$(id -u)" -eq 0 ]; then PRIV="" +elif command -v sudo >/dev/null 2>&1; then PRIV="sudo" +else PRIV="" +fi + +# Unprivileged FIRST: anyone who owns boxes is already in incus-admin, so the +# plain query answers it without making the installer demand a sudo password +# just to look. Escalate only if the socket refuses us. +incus_names() { # $1 = tag filter + incus list "$1" --format csv --columns n 2>/dev/null && return 0 + [ -n "$PRIV" ] && $PRIV incus list "$1" --format csv --columns n 2>/dev/null + return 0 +} + +boxes_on_host() { + command -v incus >/dev/null 2>&1 || return 0 + # BOTH tags: a pre-rename box carries user.claudebox=1 and is just as much + # someone's work as a current one. + { incus_names "user.box=1"; incus_names "user.claudebox=1"; } | sed '/^$/d' | sort -u +} + +if [ "$CHANGING" = 0 ]; then + log "already at $new_ver ($REPO@$REF) — reinstalling the same tree, nothing to migrate" +elif [ -z "${BOX_FORCE_UPGRADE:-}" ]; then + found="$(boxes_on_host)" + if [ -n "$found" ]; then + printf 'box-install: ERROR: this host has boxes, and this install would change what runs them.\n' >&2 + printf '\n installed: %s\n incoming: %s\n\n boxes on this host:\n' \ + "${old_from:-} ${old_ver:-}" "$REPO@$REF $new_ver" >&2 + printf '%s\n' "$found" | sed 's/^/ · /' >&2 + cat >&2 <' / 'box exec -- ...'). + · Upgrade anyway, on purpose: + BOX_FORCE_UPGRADE=1 curl -fsSL | bash + Boxes are not deleted, but the stack is rebuilt underneath them. + + A version-aware upgrade that migrates boxes instead of refusing is #67. +EOF + exit 1 + fi +fi + # --- atomically replace $DEST --------------------------------------------- log "installing into $DEST" rm -rf "$DEST" -- 2.45.2 From 043e556027d1287bacedb4e112d894b058a60655 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 17 Jul 2026 14:05:23 +0000 Subject: [PATCH 4/5] Give box-firewall.service RemainAfterExit so its state means something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the drill on a real host, which I could not do before. The unit is Type=oneshot with no RemainAfterExit, so systemd marks it 'inactive (dead)' the moment ExecStart succeeds. The rules are applied and the box-to-box drop is live, and the unit still reads as though it died. That is precisely the question people ask this unit: drill.sh's own failure hint sends you to 'systemctl status box-firewall.service' to find out whether the firewall came up, and today the honest answer and the alarming one look identical. setup-host.sh already believed this was set — 'The unit is RemainAfterExit, so once it has run it stays "active" forever' — and reasoned from it to explain why it uses restart instead of 'enable --now'. The reasoning is right and the restart is right; only the unit was missing the line the comment assumed. Verified live: before, 'nft list table bridge box' showed the drop present while is-active said inactive. After, is-active says active (exited) with the drop still present, and restart still re-applies. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +++++++++ host/box-firewall.service | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 884c6e8..23e6c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ which records not just what changed but what each drill run proved. `sudo install.sh` it would have added `root` to the group: a no-op (UID 0 opens the socket regardless) that also left the actual user locked out of their own boxes. It now derives the login user from `SUDO_USER`. +- **`box-firewall.service` now reports its state honestly** — the unit is + `Type=oneshot` and was missing `RemainAfterExit=yes`, so it went + `inactive (dead)` the instant it succeeded: a host whose isolation was + perfectly live read as one whose firewall unit had died. drill.sh sends you + to `systemctl status box-firewall` to diagnose exactly that, and + setup-host.sh's own comment already asserted the unit "is RemainAfterExit" — + it was not. Found by running the drill on a real host and mistrusting the + green: `nft list table bridge box` showed the drop live while the unit read + dead. `restart` was and remains correct either way. - **`setup-host`'s apt calls can no longer hang** — a fresh cloud image has `apt-daily`/`unattended-upgrades` holding the dpkg lock, and a plain `apt-get install` waits on it silently and indefinitely. Now bounded diff --git a/host/box-firewall.service b/host/box-firewall.service index 9b78370..de949a5 100644 --- a/host/box-firewall.service +++ b/host/box-firewall.service @@ -6,6 +6,13 @@ Wants=network.target [Service] Type=oneshot +# The unit's state must mean "the rules are applied", because that is the +# question everyone asks it — drill.sh sends you to 'systemctl status +# box-firewall' to diagnose exactly that. Without this, a oneshot goes +# 'inactive (dead)' the moment it succeeds, so a host whose isolation is +# perfectly live reads as a host whose firewall unit died. setup-host.sh's +# comment already assumed this was set; it was not. +RemainAfterExit=yes ExecStart=/usr/local/sbin/box-firewall [Install] -- 2.45.2 From d0a14a18a5c69d067abe97890456de9428665973 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 17 Jul 2026 16:10:34 +0000 Subject: [PATCH 5/5] Redesign install flow: confirm, no-op if installed, opt-in host setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the flow @danmt specified on #66. The installer now asks before it acts, and never overwrites itself. 1. "Install box?" — prompted before anything is downloaded. 2. If box is already installed: say so and stop. A re-run changes nothing, so curl|bash can no longer clobber a working tree or rebuild the host stack under live boxes. Upgrading is explicit — uninstall, then install fresh. 3. On a fresh host: download, install, link onto PATH. 4. "Set up this machine as a box host now?" — a separate decision, because the CLI and the host are not the same choice (you may host boxes elsewhere). This replaces the version-diff refusal from the previous round with the simpler rule Dan asked for: installed at all => no-op. It dissolves the same class of "the upgrade ate my boxes" errors without the installer having to reason about versions or enumerate boxes at all — you cannot lose boxes to an install that refuses to touch an existing one. Prompts read /dev/tty, because under curl|bash the script itself is stdin and a plain read would eat the installer's own remaining lines. With no terminal (CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended; without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines the second prompt. The drill uninstalls before installing (the no-op rule would otherwise refuse to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts; BOX_FORCE_UPGRADE is gone with the refusal it drove. Verified on a real host: cancel, fresh install, no-op re-run, and both prompts driven through a pty (y/n and y-then-n), plus the no-tty refusal. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 29 +++++----- README.md | 35 +++++++----- drill/drill.sh | 15 +++-- install.sh | 149 +++++++++++++++++++++---------------------------- 4 files changed, 111 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e6c9b..680c81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,19 +55,22 @@ which records not just what changed but what each drill run proved. unsets `dns.mode` and so has to be converged back. `DRILL_OWNS_SETUP=1` hands sequencing back to the drill. Pre-setup tripwires now read *before* `install.sh`, since that is what triggers setup now. -- **`install.sh` runs the host setup itself** (#64) — it printed a warning and - left you a command to run, so the install reported success and `box new` - failed on a host with no Incus. `BOX_SKIP_SETUP_HOST=1` opts out; if setup - fails, the install still stands and says what to re-run. -- **`install.sh` refuses to change versions under existing boxes** — building - the host stack from the installer means an upgrade reaches under every box - attached to that stack, so it is no longer only a tree swap. Same version and - ref: says so, changes nothing. Version or ref change with boxes on the host - (either tag generation): refuses, loudly, listing them — and refuses *before* - `$DEST` is touched, so the working install survives the refusal. No boxes: - proceeds. `BOX_FORCE_UPGRADE=1` overrides; the drill sets it, since wiping - boxes is its job. The version-aware upgrade that migrates instead of refusing - is #67. +- **`install.sh` asks, sets up the host, and no-ops on re-run** (#64) — it now + prompts *"Install box?"*, then on a fresh host installs the tree and asks a + second question, *"Set up this machine as a box host now?"*, running the whole + isolation stack if you say yes (previously it only printed a warning and left + you a command, so the install reported success and `box new` died on a host + with no Incus). Prompts read `/dev/tty`, since under `curl | bash` the script + itself is stdin; `BOX_YES=1` answers yes unattended (required where there is + no terminal), `BOX_SKIP_SETUP_HOST=1` declines the host-setup step. +- **`install.sh` never overwrites an existing install** — if box is already + installed it says so and changes nothing, so a stray re-run can no longer + clobber a working tree or rebuild the host stack under live boxes. Upgrading + is explicit: uninstall (`rm -rf ~/.local/share/box ~/.local/bin/box`, boxes + preserved first) and install fresh. This replaces the earlier version-diff + refusal with a simpler rule that dissolves the same class of errors. The + version-aware upgrade that migrates boxes instead is #67; a portable + `box export` so a box survives its own deletion is #70. ## 0.5.0 — 2026-07-15 diff --git a/README.md b/README.md index 8d422de..de9e3d1 100644 --- a/README.md +++ b/README.md @@ -40,20 +40,29 @@ design rationale. curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash ``` -Installs the tree to `~/.local/share/box` and links `box` onto your -`PATH`, then runs the host setup below for you (it may ask for `sudo`; set -`BOX_SKIP_SETUP_HOST=1` to opt out and run it yourself). Upgrading from a -pre-0.4.0 install also retires the old `claudebox` symlink. (No `git clone` -needed.) +It asks first — **"Install box?"** — then, if box is not already installed, +downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and +asks a second question: **"Set up this machine as a box host now?"** Say yes and +it builds the whole isolation stack for you (it may ask for `sudo`); say no and +you can run `box setup-host` later. (No `git clone` needed.) -**Upgrading, and boxes.** Because the installer builds the host stack, an -upgrade reaches under the boxes attached to it — so it is deliberate about it. -Re-running the same version changes nothing and says so. Changing version on a -host that has boxes **refuses**, lists them, and leaves your install exactly as -it was; deal with the boxes and re-run, or say `BOX_FORCE_UPGRADE=1` to upgrade -over them on purpose (boxes are not deleted, but the stack is rebuilt beneath -them). With no boxes on the host, it just upgrades. Migrating boxes across an -upgrade instead of refusing is [#67](https://github.com/heavy-duty/box/issues/67). +**Re-running is a safe no-op.** If box is already installed, the installer tells +you so and changes nothing — a stray re-run can never clobber your install or +rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall +what you have and install fresh. Preserve any boxes first — `box down `, +copy out anything you need (a portable `box export` is +[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm ` +(which deletes the box *and* its snapshots) — then: + +```sh +rm -rf ~/.local/share/box ~/.local/bin/box # uninstall +curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash +``` + +A version-aware upgrade that migrates boxes instead of asking you to is +[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs +(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1` +declines the host-setup step. ## One-time host setup (Ubuntu 24.04 / Debian 13) diff --git a/drill/drill.sh b/drill/drill.sh index 5de6e8a..9bb7dbe 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -202,11 +202,16 @@ EOF export BOX_SKIP_SETUP_HOST=1 fi - # The installer refuses to change versions on a host that still has boxes. - # That hatch is for humans with work to lose; the drill's whole job is to - # arrive on a dirty host, wipe every box it recognises (below) and re-prove - # the stack from there — so it opts out, deliberately and in one place. - export BOX_FORCE_UPGRADE=1 + # 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. + rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box" + + # The installer prompts (install? set up host?) and reads /dev/tty. The drill + # runs unattended with no tty, so it answers yes to everything via BOX_YES. + # OWNS still suppresses the setup prompt via BOX_SKIP_SETUP_HOST above. + export BOX_YES=1 BOX_REPO="$REPO" BOX_REF="$REF" \ bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \ diff --git a/install.sh b/install.sh index fbc1170..4eed235 100755 --- a/install.sh +++ b/install.sh @@ -17,10 +17,50 @@ log() { printf 'box-install: %s\n' "$*"; } warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; } die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; } +# Ask a yes/no question and echo the answer. The wrinkle: under the intended +# 'curl … | bash', THIS SCRIPT is stdin — so a plain 'read' would consume the +# installer's own remaining lines, not the user's keystroke. Prompts therefore +# read the terminal directly via /dev/tty. When there is no terminal at all (CI, +# a pipe with no tty), there is nobody to ask: BOX_YES=1 means "assume yes to +# every prompt" and is how automation and the drill drive this unattended; +# without it we refuse rather than silently assume consent. +confirm() { # $1 = question + [ -n "${BOX_YES:-}" ] && return 0 + if ! { true >/dev/tty; } 2>/dev/null; then + die "no terminal to confirm on. Re-run with BOX_YES=1 to proceed non-interactively (assumes yes to all prompts)." + fi + local reply + printf 'box-install: %s [y/N] ' "$1" >/dev/tty + read -r reply /dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run." command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run." +# --- confirm, then no-op if already installed ------------------------------ +# Prompt BEFORE downloading anything: the first thing a curl|bash should do is +# ask whether you meant to. Then, if box is already installed, this run changes +# nothing and says so — a re-run is a safe no-op, which dissolves the whole +# "curl clobbered my working install / rebuilt the stack under my boxes" class +# of failures. Upgrading is deliberately NOT an in-place overwrite: you uninstall +# what you have (dealing with your boxes as you do) and install fresh. +confirm "Install box from $REPO@$REF?" || die "cancelled — nothing was changed." + +if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then + cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '')" + cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')" + log "box is already installed ($cur, version $cur_ver) — nothing to do." + log "To install a different version, remove the current one first:" + log " · preserve any boxes you care about — 'box down ', then keep them" + log " (a portable 'box export' is #70; for now copy what you need OUT via" + log " 'box shell'/'box exec'), and 'box rm ' when you are done" + log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\"" + log " · then re-run this installer" + exit 0 +fi + # --- temp workspace -------------------------------------------------------- TMPDIR="$(mktemp -d)" cleanup() { rm -rf "$TMPDIR"; } @@ -47,80 +87,10 @@ EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" [ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive" [ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?" -# --- upgrade hatch --------------------------------------------------------- -# This installer builds the host stack itself now, and every box on the host is -# attached to that stack — so a version change here is not just a tree swap, it -# reaches under running boxes. Until the version-aware migration exists (#67), -# refuse rather than guess: if this would change what is installed AND there are -# boxes on the host, stop and let a human decide. Checked BEFORE $DEST is -# touched, so a refusal leaves the working install exactly as it was. -# Same version + same ref = nothing to change: say so and carry on. -new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || echo unknown)" -old_ver="$(cat "$DEST/VERSION" 2>/dev/null || true)" -old_from="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || true)" - -if [ -n "$old_ver" ] && [ "$old_ver" = "$new_ver" ] && [ "$old_from" = "$REPO@$REF" ]; then - CHANGING=0 -else - CHANGING=1 -fi - -# How we ask incus about boxes. No incus => no boxes, and nothing to protect. -if [ "$(id -u)" -eq 0 ]; then PRIV="" -elif command -v sudo >/dev/null 2>&1; then PRIV="sudo" -else PRIV="" -fi - -# Unprivileged FIRST: anyone who owns boxes is already in incus-admin, so the -# plain query answers it without making the installer demand a sudo password -# just to look. Escalate only if the socket refuses us. -incus_names() { # $1 = tag filter - incus list "$1" --format csv --columns n 2>/dev/null && return 0 - [ -n "$PRIV" ] && $PRIV incus list "$1" --format csv --columns n 2>/dev/null - return 0 -} - -boxes_on_host() { - command -v incus >/dev/null 2>&1 || return 0 - # BOTH tags: a pre-rename box carries user.claudebox=1 and is just as much - # someone's work as a current one. - { incus_names "user.box=1"; incus_names "user.claudebox=1"; } | sed '/^$/d' | sort -u -} - -if [ "$CHANGING" = 0 ]; then - log "already at $new_ver ($REPO@$REF) — reinstalling the same tree, nothing to migrate" -elif [ -z "${BOX_FORCE_UPGRADE:-}" ]; then - found="$(boxes_on_host)" - if [ -n "$found" ]; then - printf 'box-install: ERROR: this host has boxes, and this install would change what runs them.\n' >&2 - printf '\n installed: %s\n incoming: %s\n\n boxes on this host:\n' \ - "${old_from:-} ${old_ver:-}" "$REPO@$REF $new_ver" >&2 - printf '%s\n' "$found" | sed 's/^/ · /' >&2 - cat >&2 <' / 'box exec -- ...'). - · Upgrade anyway, on purpose: - BOX_FORCE_UPGRADE=1 curl -fsSL | bash - Boxes are not deleted, but the stack is rebuilt underneath them. - - A version-aware upgrade that migrates boxes instead of refusing is #67. -EOF - exit 1 - fi -fi - -# --- atomically replace $DEST --------------------------------------------- +# --- install into $DEST ---------------------------------------------------- +# Reached only on a host with no existing install (the no-op check above +# exits otherwise), so this is always a fresh tree, never an overwrite. log "installing into $DEST" -rm -rf "$DEST" mkdir -p "$(dirname "$DEST")" mv "$EXTRACTED" "$DEST" @@ -163,36 +133,43 @@ esac # and it must not hinge on whether the host stack came up. printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM" -# --- host setup ------------------------------------------------------------ -# The installer finishes the job (#64). Telling the user to go run setup-host -# was a step that read as optional and failed later as mysterious: the install -# reports success, 'box' is on PATH, and 'box new' dies on a host with no -# Incus, no boxnet, no profile. setup-host is idempotent by design, so doing -# this on EVERY install is also how an upgraded host picks up stack changes — -# the isolation fixes that ship as new firewall rules land when the tool that -# claims them lands, instead of waiting on someone to re-run a command. -# BOX_SKIP_SETUP_HOST=1 opts out: CI, image builds, a host set up by hand. +# --- host setup (second prompt) -------------------------------------------- +# The tool is installed; the machine is not yet a box host. Offer to finish the +# job — build Incus and the isolation stack — rather than leave 'box new' to die +# later on a host with no boxnet and no profile (#64). This is its own decision: +# you might be installing the CLI on a workstation and hosting boxes elsewhere. +# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set +# up by hand); BOX_YES answers "yes". setup_ok="" +setup_declined="" if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then - log "skipping host setup (BOX_SKIP_SETUP_HOST is set) — run it yourself: box setup-host" + log "skipping host setup (BOX_SKIP_SETUP_HOST is set)." + setup_declined=1 elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then - warn "host setup needs root and sudo was not found." + warn "cannot set up the host: it needs root and sudo was not found." warn " run this as root to finish: $DEST/host/setup-host.sh" -else - log "running one-time host setup (installs Incus + the isolation stack; may ask for sudo)" + setup_declined=1 +elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then #