Refuse to change versions under existing boxes; file the migration as #67

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) <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-17 13:34:53 +00:00
parent ce3a0c5076
commit 3e1143f1d3
4 changed files with 100 additions and 7 deletions

View file

@ -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

View file

@ -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)

View file

@ -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; }

View file

@ -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:-<none>} ${old_ver:-<no version file>}" "$REPO@$REF $new_ver" >&2
printf '%s\n' "$found" | sed 's/^/ · /' >&2
cat >&2 <<EOF
Refusing, and nothing has been changed — your current install is intact.
The installer now builds the host stack (network, ACL, profile, firewall)
itself, so upgrading reaches under boxes that are attached to it.
Your options:
· Stay where you are. The boxes keep working. Nothing to do.
· Deal with the boxes, then re-run this installer.
NOTE: 'box rm' deletes a box AND every snapshot it has — it cannot be
undone, and a snapshot does NOT survive its box. Copy anything you need
OUT of a box first ('box shell <box>' / 'box exec <box> -- ...').
· Upgrade anyway, on purpose:
BOX_FORCE_UPGRADE=1 curl -fsSL <this url> | 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"