diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2846ec..94582f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: # release still proves the boundary. rehearsal: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 40 steps: - uses: actions/checkout@v4 - name: install incus @@ -58,6 +58,33 @@ jobs: run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh - name: multi-user rehearsal (criteria a-l, container mode) run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container + # The #70 round-trip, on the SAME live daemon: a box's state must + # survive 'box rm' via export → import. Container mode for the same + # reason the rehearsal uses it — export/import are backup mechanics + # (tarball out, tarball in, re-stamp), identical across instance types; + # the VM trust boundary stays a real-hardware ritual. Every assertion + # is state observed AFTER the original box was deleted: the file + # written pre-export, the snapshot, the boundary tag, a live agent. + - name: export/import round-trip — state survives 'box rm' (#70) + run: | + set -eux + sudo box new --name keeper --container + sudo box exec keeper -- sh -c 'echo survives > /home/dev/proof' + sudo box snapshot keeper pre-export + sudo box down keeper + sudo box export keeper /tmp/keeper.tar.gz + sudo test -s /tmp/keeper.tar.gz + sudo box rm keeper --force + sudo box import /tmp/keeper.tar.gz --name keeper2 + test "$(sudo incus config get keeper2 user.box)" = 1 + sudo incus exec keeper2 -- true + sudo box exec keeper2 -- cat /home/dev/proof | grep -qx survives + sudo incus snapshot list keeper2 --format csv | grep -q '^pre-export' + # the collision boundary, live: the name is taken, import must refuse + if sudo box import /tmp/keeper.tar.gz --name keeper2; then + echo 'collision was not refused'; exit 1 + fi + sudo box rm keeper2 --force - name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue # The full-removal order, end to end on the real daemon: revoke a # granted user (--purge asserts its own absence, incl. the incus-user diff --git a/CHANGELOG.md b/CHANGELOG.md index bac2f2e..ed85af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ which records not just what changed but what each drill run proved. ### Added +- **`box export` / `box import`** (#70) — a box's state that survives the box + _and_ the host, unblocking #66's humane upgrade flow (down, export, rm, + upgrade, re-import). `box export []` wraps `incus export` into + one portable backup tarball (default `-.tar.gz`), snapshots + included by default (`--instance-only` opts out); the box must be stopped + first (`box down`) so the artifact is a settled disk, not a moving one. The + file is **shouted about, not scrubbed** — it carries the box's whole disk + (agent logins, git credentials, SSH keys), and scrubbing a disk image is a + promise tarball surgery cannot keep, so box says what is inside instead, + every time. `box import [--name ]` mints the box back and + re-stamps what is the _current host's_ truth, not the artifact's: the + `user.box=1` boundary tag (legacy `user.claudebox=1` honored), the + `box-net` placement (re-assigned if the artifact's differs — the + migrate-host move), and a fresh machine identity: the NIC's MAC (imports + restore `volatile.*` verbatim, and a re-import beside its sibling collided + at start with "MAC address already defined on another NIC" — measured + live; `incus copy` regenerates it, `incus import` does not) plus + `reset_identity` (the clone trust boundary: no DHCP collision with the box + it was exported from). + Import refuses any name an existing instance holds — the `resolve_box` + boundary, seen from the other side. Works on both tiers: `box grant` now + also converges `restricted.backups allow` (incus-user blocks backups by + default exactly like snapshots, and an export _is_ a backup + create+download — measured against incus 6.0's `permissions.go`); re-run + `box grant ` after upgrading, as documented. CI's `rehearsal` job now + proves the round-trip on a live Incus: mint → write a file → snapshot → + down → export → `rm` → import → the file and the snapshot survived, the + agent answers, the tag is present, and a colliding re-import is refused. - **Versioned installs** (#66's stance, made livable) — install.sh now lands each version side by side at `/versions/` (its own `VERSION` + `INSTALLED_FROM`), with a `current` symlink tracking the default and diff --git a/README.md b/README.md index d73db51..64b3764 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ boxes. Installing a **new** version lands it side by side and flips `current` only when you have **no boxes** — under existing boxes the flip is refused (never change versions under a user's boxes, [#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a -deliberate act: preserve what you care about — `box down `, copy out -anything you need (a portable `box export` is +deliberate act: preserve what you care about — `box down `, then +`box export ` (one portable file per box, snapshots included — [#70](https://github.com/heavy-duty/box/issues/70)), then `box rm ` (which deletes the box _and_ its snapshots) — then: @@ -77,8 +77,10 @@ box use # flip the default (same refusal while boxes exist) A pre-0.7.0 flat install is migrated into `versions/` automatically on the next installer run — the tree is moved, not re-downloaded, and your boxes are -untouched. A version-aware upgrade that migrates boxes instead of asking you -to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended +untouched. After switching versions (and `box setup-host`, if the stack was +torn down), `box import ` brings each exported box back — snapshots, +logins and all. 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, `BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and `BOX_INSTALL_SOURCE=` installs from a local tree instead of @@ -159,7 +161,8 @@ user to a private _unhardened_ NAT bridge, it blocks snapshots, and it cannot see the `box-net` profile. Granting rewires all three: the user's project is restricted to `boxnet` **and only boxnet** — the hardened network is not their default placement but the only one their certificate can express — snapshots -are allowed, and the shipped profile is installed into their project. Re-run +and backups are allowed (the clone and `box export` workflows), and the +shipped profile is installed into their project. Re-run `box grant ` after upgrading box to refresh the profile, like `setup-host` for the stack. @@ -244,6 +247,38 @@ a box's live state, or roll a box back with `box restore work authed`. Forgotten what you called a checkpoint? `box info work` prints the box's snapshot labels and the `--from` line to clone one. +## Survive the host: `box export` / `box import` + +Snapshots live _inside_ a box, and `box rm` deletes the box **and** its +snapshots. `box new --from` clones — but the clone still lives on the same +host, under the same stack. `box export` is the way out +([#70](https://github.com/heavy-duty/box/issues/70)): one portable file that +outlives the box, the host stack, and the machine. + +```sh +box down work # export wants a settled disk +box export work # → work-.tar.gz, snapshots included +box rm work # nothing is lost anymore +# ...upgrade box / rebuild the host / carry the file to another machine... +box import work-.tar.gz # the box is back — snapshots, logins and all +box import work-.tar.gz --name work2 # or under a new name +``` + +This is what makes the upgrade flow humane +([#66](https://github.com/heavy-duty/box/issues/66)): stop, export, remove +every box, upgrade, re-import. Everything `incus import` restores is the +artifact's truth (disk, config, snapshots); what box re-stamps on import is +_this_ host's truth — the `user.box=1` boundary tag, the `box-net` placement +(re-assigned if the artifact's differs), and a fresh machine identity, the +same move a clone gets, so an imported box can never collide with the box it +was exported from. Import refuses a name any existing instance already holds. +`--instance-only` exports the live state without the snapshots. + +**The file is a credential.** A box's disk carries everything inside it — +agent logins, git PATs, SSH keys, shell history. Export scrubs nothing (a +"scrubbed" disk image would be a lie) and shouts instead, every time. Store +and move the file like the secret it is. + ## See a dev server: `box expose` The isolation contract says no inbound path exists — which is one "no" too @@ -276,6 +311,10 @@ box exec -- # run a command in the box box tmux [session] # attach/create a tmux session — survives disconnects box snapshot [label] # checkpoint (label defaults to manual-) box restore # roll back to a snapshot +box export [] [--instance-only] + # one portable file (snapshots incl.) — survives rm & host +box import [--name ] + # mint a box back from an exported file, re-stamped box rename # rename a box (stop it first) box down # stop (state kept; `start` resumes) box start # start a stopped box diff --git a/bin/box b/bin/box index f764317..0e13c94 100755 --- a/bin/box +++ b/bin/box @@ -7,7 +7,7 @@ set -euo pipefail root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0 -cpu=""; memory=""; disk="" +cpu=""; memory=""; disk=""; instance_only=0 inst="" # the resolved Incus instance, set by the 'box' precondition die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong @@ -65,6 +65,8 @@ CMDS=( "tmux^ []^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^" "snapshot^ [