README's install/upgrade/uninstall sections rewritten for the versioned layout (side-by-side installs, 'box use' flips, 'box uninstall' replaces the rm -rf prose); CHANGELOG Unreleased entries; the plan doc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.5 KiB
Versioned installs, and a real uninstall (0.7.0 core)
Status: implemented and tested. 154/154 in test/cli.sh (which now
drives real installer runs, not greps of them), shellcheck clean, and CI's
rehearsal job installs via install.sh itself and ends with a zero-residue
uninstall drill on a live Incus. This doc records the design and why each
decision fell where it did.
What was asked
Two maintainer requests, one PR:
- Install should be versioned — each box version goes to its own folder
and a tracked default names the one you run, like plenty of CLIs manage
theirs. Before this,
install.shrefused to touch an existing install at all: changing versions meant uninstalling by hand (rm -rftwo paths from the README) and re-running the installer. - Uninstall is flaky — there was no uninstall verb at all, only prose;
teardown-host.shdeliberately leaves the install tree; and nothing encoded the safe full-removal order (revoke users → teardown-host → remove trees/symlinks). Add thorough tests for uninstall, and for grant/revoke that they are clean.
The layout
<root>/ /opt/box (root) or ~/.local/share/box (user);
BOX_HOME overrides — both unchanged from #71
versions/<version>/ one full tree per version, each with its own
VERSION + INSTALLED_FROM
current -> versions/<v> the tracked default (a relative symlink, so the
root can move as a unit)
$BINDIR/box -> <root>/current/bin/box
The version key is the tree's own VERSION file — the identity of what was
installed, and the name box versions lists. bin/box needed no change
to run from here: line 8 already derives $root via readlink -f, which
resolves the whole $BINDIR/box → current → versions/<v> chain, so
VERSION, templates/, host/ and drill/ all resolve inside the version
tree that is actually running. That same fact is how the new verbs detect
their world: a versioned install always runs from .../versions/<v>; a git
checkout does not, and the verbs refuse instead of uninstalling somebody's
working copy.
Install semantics (#66's stance, kept — at the flip)
#66 established: a stray installer re-run must never clobber a working install or rebuild the stack under existing boxes. The old enforcement was a blanket "refuse if anything is installed", which also blocked upgrades. The versioned layout splits the two concerns:
- Same version present → converging no-op ("already installed", exit 0);
BOX_REINSTALL=1replaces that version's tree via two renames (never a partial overlay). A converge/reinstall of a non-current version never moves the default — switching isbox use, a deliberate act. - Different version → installs side-by-side, then flips
currentonly when no boxes exist. With boxes present (both tag generations, checked at the caller's tier via a sharedexisting_boxes()— byte-identical ininstall.shandbin/box, diffed by the tests so the two #66 stances cannot drift), the flip is refused loudly, the boxes are named, and the operator is pointed at the remedy: down/copy-out/rm, thenbox use <v>. A daemon that is absent or not answering has no boxes to protect — the stance guards boxes, not daemons. - Pre-0.7.0 flat tree → migrated before anything else:
mvthe root aside,mkdir versions/,mvit toversions/<its-VERSION>, linkcurrentand$BINDIR/box. Two renames inside one parent directory — no copy, no window with no install, the operator's tree preserved bit for bit (the tests assert the migrated tree's ownINSTALLED_FROMsurvives). - Wedged symlinks → healed, never trusted. The old no-op check keyed off
$BINDIR/boxor$DEST/bin/boxexisting, so a stale symlink (or a half-removed tree) faked "already installed" forever. Installed-ness is now judged fromversions/<v>itself;ln -sfnconverges the links. - Tier coexistence → a root and a per-user install shadow each other by PATH order alone; the installer warns when it sees the other tier's tree.
- Host setup is offered on fresh hosts only — an upgraded host has made
that decision (and may have live boxes the stack must not be rebuilt
under);
box setup-hostre-applies stack changes deliberately.
BOX_INSTALL_SOURCE=<dir-or-tarball> bypasses the download (a directory is
tar-copied with --exclude=.git). This exists for CI and the drill — the
code under review is what lands — and it is what turned the test suite's
install coverage from greps into real runs.
The new verbs
Table rows like every other verb (the CMDS table stays the single source of
truth); uninstall joins the host-verb flag passthrough so --all /
--purge-host reach it.
box versions— listsversions/*, marking the current default and the tree answering the command (they differ when another install shadows yours on PATH).box use <version>— same existing-boxes refusal as the installer's flip (shared helper, boxes named), then repointscurrent, converges every PATH symlink that resolves into this install root (never one that is somebody else's), and asserts the effective result:currentmust resolve to the asked-for version andcurrent/bin/box --versionmust answer it. A flip that "worked" while the operator still runs the old tree is exactly the flakiness this verb exists to end.box uninstall [<version>] [--all] [--purge-host]—- one version: refuses the current one; removes the dir; re-checks it.
- full: the safe order — refuse while boxes exist (naming them) unless
--purge-hostrunsteardown-host.shfirst (its own confirmation; a note names granted users' surviving projects andrevoke --purgeas the clean path); confirm (--force/BOX_YES=1— installer-family consent, deliberately not the lifecycleconfirm(), which must never auto-accept from the environment); gather the removal set (root, every PATH symlink pointing into it, claudebox crumbs of both name generations); remove; then the absence assert: every path re-checked for file/dir/symlink existence, any survivor → exit 1uninstall INCOMPLETEnaming the leftovers.rm's exit code is not the verdict — the re-check is (a half-removed tree is INCOMPLETE, not a crash).
Grant/revoke cleanliness
Reading revoke-user.sh against its own absence assert found the gap: the
purge removes /var/lib/incus/users/<uid> but never re-checks it — and the
stat was a bare [ -d ], which lies for a non-root admin (/var/lib/incus
is not traversable, so the directory reads as absent while it is there).
Both fixed: the check rides $SUDO test -d, and the absence block now covers
the state dir. Grepped-and-guarded in test/cli.sh; drilled live in CI.
Tests (the heart of this PR)
test/cli.sh stays dependency-free, non-root, daemon-free. New machinery: a
fake incus on PATH whose list prints $FAKE_BOXES, throwaway
BOX_HOME/BOX_BIN roots, and fabricated second/third sources with
different VERSIONs. Driven end to end: fresh layout + chain
(box --version through both symlinks), no-op/canary, BOX_REINSTALL,
side-by-side + no-boxes flip, all three #66 refusals (install flip, use,
uninstall — boxes named, remedies named), versions markers, use
flip-and-assert, flat-tree migration (alone, and combined with an upgrade),
dangling- and stale-symlink healing, single-version uninstall (current
refused), full uninstall with planted legacy crumbs and a zero-residue
assert (files and symlinks and legacy names), the INCOMPLETE scream
(a chmod-pinned survivor), and refusals from a working tree. The existing
DEST/BINDIR-branch tests are kept unchanged (the branch itself is unchanged).
CI's rehearsal job now installs via install.sh
(BOX_INSTALL_SOURCE=$GITHUB_WORKSPACE), asserts the layout it left, runs
the stack from /opt/box/current/..., and appends the uninstall drill:
grant + revoke --purge a throwaway user (asserting the incus-user state
dir is gone), teardown-host (new --yes/BOX_YES support), box uninstall --all, then zero residue — networks, profiles, ACLs, nft tables,
systemd units, files, symlinks, both name generations.
What this is not
- Not #67: boxes still do not migrate across versions — this PR delivers the version-agnostic upgrade path (side-by-side installs, an explicit flip); data migration remains #67.
- Not a release:
VERSIONis untouched (a release PR bumps it).