feat: box export / import — state that survives the box and the host (#70) #78
10 changed files with 567 additions and 44 deletions
29
.github/workflows/ci.yml
vendored
29
.github/workflows/ci.yml
vendored
|
|
@ -33,7 +33,7 @@ jobs:
|
||||||
# release still proves the boundary.
|
# release still proves the boundary.
|
||||||
rehearsal:
|
rehearsal:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: install incus
|
- name: install incus
|
||||||
|
|
@ -58,6 +58,33 @@ jobs:
|
||||||
run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh
|
run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh
|
||||||
- name: multi-user rehearsal (criteria a-l, container mode)
|
- name: multi-user rehearsal (criteria a-l, container mode)
|
||||||
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container
|
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
|
- name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue
|
||||||
# The full-removal order, end to end on the real daemon: revoke a
|
# The full-removal order, end to end on the real daemon: revoke a
|
||||||
# granted user (--purge asserts its own absence, incl. the incus-user
|
# granted user (--purge asserts its own absence, incl. the incus-user
|
||||||
|
|
|
||||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -7,6 +7,34 @@ which records not just what changed but what each drill run proved.
|
||||||
|
|
||||||
### Added
|
### 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 <box> [<file>]` wraps `incus export` into
|
||||||
|
one portable backup tarball (default `<box>-<UTC stamp>.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 <file> [--name <box>]` 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 <user>` 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
|
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
|
||||||
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
|
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
|
||||||
`INSTALLED_FROM`), with a `current` symlink tracking the default and
|
`INSTALLED_FROM`), with a `current` symlink tracking the default and
|
||||||
|
|
|
||||||
49
README.md
49
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
|
only when you have **no boxes** — under existing boxes the flip is refused
|
||||||
(never change versions under a user's boxes,
|
(never change versions under a user's boxes,
|
||||||
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
|
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
|
||||||
deliberate act: preserve what you care about — `box down <box>`, copy out
|
deliberate act: preserve what you care about — `box down <box>`, then
|
||||||
anything you need (a portable `box export` is
|
`box export <box>` (one portable file per box, snapshots included —
|
||||||
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
||||||
(which deletes the box _and_ its snapshots) — then:
|
(which deletes the box _and_ its snapshots) — then:
|
||||||
|
|
||||||
|
|
@ -77,8 +77,10 @@ box use <version> # flip the default (same refusal while boxes exist)
|
||||||
|
|
||||||
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
|
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
|
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
|
untouched. After switching versions (and `box setup-host`, if the stack was
|
||||||
to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
|
torn down), `box import <file>` 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,
|
installs (CI, images), `BOX_YES=1` answers every prompt yes,
|
||||||
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
|
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
|
||||||
`BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree instead of
|
`BOX_INSTALL_SOURCE=<dir-or-tarball>` 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
|
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
|
restricted to `boxnet` **and only boxnet** — the hardened network is not their
|
||||||
default placement but the only one their certificate can express — snapshots
|
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 <user>` after upgrading box to refresh the profile, like
|
`box grant <user>` after upgrading box to refresh the profile, like
|
||||||
`setup-host` for the stack.
|
`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
|
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||||
snapshot labels and the `--from` line to clone one.
|
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-<UTC stamp>.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-<stamp>.tar.gz # the box is back — snapshots, logins and all
|
||||||
|
box import work-<stamp>.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`
|
## See a dev server: `box expose`
|
||||||
|
|
||||||
The isolation contract says no inbound path exists — which is one "no" too
|
The isolation contract says no inbound path exists — which is one "no" too
|
||||||
|
|
@ -276,6 +311,10 @@ box exec <box> -- <cmd...> # run a command in the box
|
||||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||||
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
||||||
box restore <box> <snap> # roll back to a snapshot
|
box restore <box> <snap> # roll back to a snapshot
|
||||||
|
box export <box> [<file>] [--instance-only]
|
||||||
|
# one portable file (snapshots incl.) — survives rm & host
|
||||||
|
box import <file> [--name <box>]
|
||||||
|
# mint a box back from an exported file, re-stamped
|
||||||
box rename <box> <new> # rename a box (stop it first)
|
box rename <box> <new> # rename a box (stop it first)
|
||||||
box down <box> # stop (state kept; `start` resumes)
|
box down <box> # stop (state kept; `start` resumes)
|
||||||
box start <box> # start a stopped box
|
box start <box> # start a stopped box
|
||||||
|
|
|
||||||
250
bin/box
250
bin/box
|
|
@ -7,7 +7,7 @@ set -euo pipefail
|
||||||
|
|
||||||
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
|
||||||
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
|
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
|
inst="" # the resolved Incus instance, set by the 'box' precondition
|
||||||
|
|
||||||
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
||||||
|
|
@ -65,6 +65,8 @@ CMDS=(
|
||||||
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
|
||||||
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
|
||||||
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:snapshot restore^restored {} to {1}"
|
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:snapshot restore^restored {} to {1}"
|
||||||
|
"export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^"
|
||||||
|
"import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^"
|
||||||
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
|
||||||
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
||||||
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
||||||
|
|
@ -151,14 +153,15 @@ EOF
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
|
|
||||||
OPTIONS
|
OPTIONS
|
||||||
--name <box> Name for the new box (new)
|
--name <box> Name for the new box (new, import)
|
||||||
--template <t> Template to mint from (default: blank) (new)
|
--template <t> Template to mint from (default: blank) (new)
|
||||||
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
||||||
--vm Force VM mode: the trust-less target (new)
|
--vm Force VM mode: the trust-less target (new)
|
||||||
--container Force container mode: weaker isolation, (new)
|
--container Force container mode: weaker isolation, (new)
|
||||||
dev/test only. Default where /dev/kvm is absent.
|
dev/test only. Default where /dev/kvm is absent.
|
||||||
--json Emit Incus JSON instead of a table (list, info)
|
--json Emit Incus JSON instead of a table (list, info)
|
||||||
--force, -f Delete without the confirmation prompt (rm)
|
--instance-only Export the live state only, no snapshots (export)
|
||||||
|
--force, -f Delete without asking (rm); overwrite the file (export)
|
||||||
--remote <r> Act on Incus remote <r> (any)
|
--remote <r> Act on Incus remote <r> (any)
|
||||||
--help, -h Help; after a command, help for that command
|
--help, -h Help; after a command, help for that command
|
||||||
--version, -V Print the box version
|
--version, -V Print the box version
|
||||||
|
|
@ -182,6 +185,11 @@ EXAMPLES
|
||||||
box list
|
box list
|
||||||
box info work
|
box info work
|
||||||
|
|
||||||
|
# a box that outlives this host: one portable file, credentials inside
|
||||||
|
box down work
|
||||||
|
box export work # → work-<UTC stamp>.tar.gz — guard it like a credential
|
||||||
|
box import work-<stamp>.tar.gz --name work2
|
||||||
|
|
||||||
# run something without opening a shell
|
# run something without opening a shell
|
||||||
box exec work -- git -C project pull
|
box exec work -- git -C project pull
|
||||||
|
|
||||||
|
|
@ -329,6 +337,57 @@ that snapshot is lost. 'box info <box>' lists the labels.
|
||||||
|
|
||||||
box restore work authed
|
box restore work authed
|
||||||
EOF
|
EOF
|
||||||
|
;;
|
||||||
|
export) cat <<'EOF'
|
||||||
|
One portable file that outlives the box AND the host. 'box rm' deletes a box
|
||||||
|
and every snapshot it has; 'box new --from' clones, but the clone still lives
|
||||||
|
on the same host. Export is the way out (#70): it wraps 'incus export' into a
|
||||||
|
backup tarball of the whole instance — snapshots included by default, because
|
||||||
|
the reuse workflow (log in once, snapshot, clone forever) lives in them.
|
||||||
|
|
||||||
|
box export <box> [<file>] # default file: <box>-<UTC timestamp>.tar.gz
|
||||||
|
--instance-only # live state only, leave the snapshots behind
|
||||||
|
--force # overwrite an existing <file> (refused otherwise)
|
||||||
|
|
||||||
|
The box must be stopped first ('box down <box>'). Incus can back up a running
|
||||||
|
instance, but a live root disk is a moving target — and this artifact's whole
|
||||||
|
job is to be trusted later, on a host that no longer has the box.
|
||||||
|
|
||||||
|
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 is a promise tarball surgery cannot keep) and says so
|
||||||
|
loudly instead. Store and move the file like the secret it is.
|
||||||
|
|
||||||
|
The upgrade flow this unblocks (#66):
|
||||||
|
|
||||||
|
box down work && box export work # one file per box
|
||||||
|
box rm work # nothing is lost anymore
|
||||||
|
# ...upgrade box / rebuild the host / move machines...
|
||||||
|
box import work-<stamp>.tar.gz # the box is back, snapshots and all
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
import) cat <<'EOF'
|
||||||
|
Mint a box from a 'box export' file — on this host or any other that has the
|
||||||
|
box stack ('box setup-host' builds it). The name inside the tarball is used
|
||||||
|
unless --name picks another; either way the name must be free: import will
|
||||||
|
not occupy a name ANY existing instance holds, box or not.
|
||||||
|
|
||||||
|
Everything 'incus import' restores is the artifact's truth (disk, config,
|
||||||
|
snapshots). What box then re-stamps is THIS host's truth:
|
||||||
|
|
||||||
|
· the user.box=1 boundary tag (a legacy user.claudebox=1 stays honored)
|
||||||
|
· the box-net placement — re-assigned if the artifact's profile list
|
||||||
|
differs, the same move migrate-host makes re-homing a legacy box
|
||||||
|
· a fresh machine identity (reset_identity, exactly like a clone), so its
|
||||||
|
DHCP lease can never collide with the box it was exported from
|
||||||
|
|
||||||
|
Auth state rides along by design — the artifact carries the box's whole disk,
|
||||||
|
logins included. That is the point (log in once, keep the file), and the same
|
||||||
|
trust boundary as cloning an authed snapshot.
|
||||||
|
|
||||||
|
box import work-20260718T120000Z.tar.gz
|
||||||
|
box import work-20260718T120000Z.tar.gz --name work2
|
||||||
|
EOF
|
||||||
;;
|
;;
|
||||||
rename) cat <<'EOF'
|
rename) cat <<'EOF'
|
||||||
Rename a box. Incus cannot rename a running instance, so stop it first:
|
Rename a box. Incus cannot rename a running instance, so stop it first:
|
||||||
|
|
@ -439,12 +498,13 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades):
|
||||||
· points the project at boxnet and ONLY boxnet — the private incusbr-<uid>
|
· points the project at boxnet and ONLY boxnet — the private incusbr-<uid>
|
||||||
bridge incus-user auto-creates carries none of box's hardening, so it is
|
bridge incus-user auto-creates carries none of box's hardening, so it is
|
||||||
unreferenced and unreachable, not just unused
|
unreferenced and unreachable, not just unused
|
||||||
· allows snapshots (incus-user blocks them; box's clone workflow needs them)
|
· allows snapshots and backups (incus-user blocks both; the clone workflow
|
||||||
|
rides snapshots, 'box export' rides backups — #70)
|
||||||
· installs the box-net profile into their project
|
· installs the box-net profile into their project
|
||||||
|
|
||||||
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/rm on
|
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
|
||||||
their own boxes. Not theirs: expose (edits daemon-global state), setup-host,
|
import/rm on their own boxes. Not theirs: expose (edits daemon-global
|
||||||
grant. Admin boxes and other users' boxes are invisible to them, and the
|
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
|
||||||
existing box-to-box drop means even their instances cannot reach a sibling.
|
existing box-to-box drop means even their instances cannot reach a sibling.
|
||||||
|
|
||||||
box grant dev1
|
box grant dev1
|
||||||
|
|
@ -500,8 +560,8 @@ EOF
|
||||||
Switch the default box version — repoint the 'current' symlink (and the PATH
|
Switch the default box version — repoint the 'current' symlink (and the PATH
|
||||||
symlink riding it) at an installed version. Refuses while ANY box exists:
|
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,
|
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'
|
'box export' what you keep (one
|
||||||
is #70), 'box rm' each box, then switch. The flip is asserted afterwards:
|
portable file per box, #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
|
current must resolve to the version you asked for, and the chain's
|
||||||
'box --version' must answer it.
|
'box --version' must answer it.
|
||||||
|
|
||||||
|
|
@ -577,6 +637,7 @@ while [ $# -gt 0 ]; do
|
||||||
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
|
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
|
||||||
--vm) mode=vm; shift ;;
|
--vm) mode=vm; shift ;;
|
||||||
--container) mode=container; shift ;;
|
--container) mode=container; shift ;;
|
||||||
|
--instance-only) instance_only=1; shift ;;
|
||||||
--force|-f) force=1; shift ;;
|
--force|-f) force=1; shift ;;
|
||||||
--json) json=1; shift ;;
|
--json) json=1; shift ;;
|
||||||
--help|-h) want_help=1; shift ;;
|
--help|-h) want_help=1; shift ;;
|
||||||
|
|
@ -622,14 +683,35 @@ resolve_box() {
|
||||||
|
|
||||||
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
|
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
|
||||||
|
|
||||||
|
# $3, when given, replaces the default "why" — the table's 'stopped' rows are
|
||||||
|
# stopped because INCUS insists (rename), but export is stopped by OUR
|
||||||
|
# decision (a consistent artifact), and the refusal should say the true reason.
|
||||||
require_stopped() {
|
require_stopped() {
|
||||||
local i="$1" box="$2" st; st="$(box_state "$i")"
|
local i="$1" box="$2" why="${3:-Incus needs it stopped for this}" st; st="$(box_state "$i")"
|
||||||
case "$st" in
|
case "$st" in
|
||||||
STOPPED|Stopped|stopped) return 0 ;;
|
STOPPED|Stopped|stopped) return 0 ;;
|
||||||
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: box down $box" ;;
|
*) die "box '$box' is ${st:-not stopped} — $why. Stop it: box down $box" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The placement contract must exist before a mint OR an import lands — in the
|
||||||
|
# DEFAULT project for an admin (setup-host builds it), in YOUR project for a
|
||||||
|
# restricted user (box grant converges it). Its absence has a different fix
|
||||||
|
# per tier, and incus's own "Profile not found" at launch time names neither.
|
||||||
|
require_stack() {
|
||||||
|
if [ -z "$remote" ] && ! timeout 10 incus profile show box-net >/dev/null 2>&1 </dev/null; then
|
||||||
|
# A missing profile and a daemon that is not answering are different
|
||||||
|
# faults with different fixes — "run setup-host" at a wedged daemon
|
||||||
|
# (the #26 shape) is wrong advice. Separate them before diagnosing.
|
||||||
|
timeout 10 incus list >/dev/null 2>&1 </dev/null \
|
||||||
|
|| die "the incus daemon is not answering — diagnose it: box doctor"
|
||||||
|
if [ "$(box_tier)" = restricted ]; then
|
||||||
|
die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
|
||||||
|
fi
|
||||||
|
die "no box-net profile — the host stack is missing. Build it: box setup-host"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
need_name() {
|
need_name() {
|
||||||
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
||||||
usage_error "usage: $(synopsis_of "$cmd")"
|
usage_error "usage: $(synopsis_of "$cmd")"
|
||||||
|
|
@ -723,7 +805,7 @@ wait_agent() {
|
||||||
# the reset costs one reboot. Do it before handing the box over, never after.
|
# the reset costs one reboot. Do it before handing the box over, never after.
|
||||||
reset_identity() {
|
reset_identity() {
|
||||||
local i="$1"
|
local i="$1"
|
||||||
echo "box: giving the clone its own identity (machine-id, DHCP lease)..."
|
echo "box: giving the instance its own identity (machine-id, DHCP lease)..."
|
||||||
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
|
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
|
||||||
# down cleanly, so the graceful stop hangs and the reboot never happens —
|
# down cleanly, so the graceful stop hangs and the reboot never happens —
|
||||||
# leaving the clone on its source's identity, which is the bug we are here to
|
# leaving the clone on its source's identity, which is the bug we are here to
|
||||||
|
|
@ -808,21 +890,7 @@ cmd_templates() {
|
||||||
|
|
||||||
cmd_new() {
|
cmd_new() {
|
||||||
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
||||||
# The placement contract must exist before any mint — in the DEFAULT project
|
require_stack
|
||||||
# for an admin (setup-host builds it), in YOUR project for a restricted user
|
|
||||||
# (box grant converges it). Its absence has a different fix per tier, and
|
|
||||||
# incus's own "Profile not found" at launch time names neither.
|
|
||||||
if [ -z "$remote" ] && ! timeout 10 incus profile show box-net >/dev/null 2>&1 </dev/null; then
|
|
||||||
# A missing profile and a daemon that is not answering are different
|
|
||||||
# faults with different fixes — "run setup-host" at a wedged daemon
|
|
||||||
# (the #26 shape) is wrong advice. Separate them before diagnosing.
|
|
||||||
timeout 10 incus list >/dev/null 2>&1 </dev/null \
|
|
||||||
|| die "the incus daemon is not answering — diagnose it: box doctor"
|
|
||||||
if [ "$(box_tier)" = restricted ]; then
|
|
||||||
die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
|
|
||||||
fi
|
|
||||||
die "no box-net profile — the host stack is missing. Build it: box setup-host"
|
|
||||||
fi
|
|
||||||
local instance; instance="$(iname_of "$name")"
|
local instance; instance="$(iname_of "$name")"
|
||||||
if [ -n "$from" ]; then
|
if [ -n "$from" ]; then
|
||||||
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
|
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
|
||||||
|
|
@ -1068,6 +1136,130 @@ cmd_status() {
|
||||||
list_all
|
list_all
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- export / import (#70): state that survives the box and the host --------
|
||||||
|
#
|
||||||
|
# 'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
|
||||||
|
# but the clone still lives on the same host under the same stack. So until
|
||||||
|
# now, nothing a box held could outlive a host teardown — and #66's installer
|
||||||
|
# refusal ("stop, export, remove every box before you upgrade") was only
|
||||||
|
# honest advice once a real export existed. 'incus export' is the primitive:
|
||||||
|
# a backup tarball of the instance and (by default) its snapshots.
|
||||||
|
cmd_export() {
|
||||||
|
local box="${args[0]}" file="${args[1]:-}"
|
||||||
|
# Down first, by OUR decision, not incus's necessity: incus can back up a
|
||||||
|
# running instance, but a live root disk is a moving target, and this
|
||||||
|
# artifact's whole job is to be trusted later, on a host that no longer
|
||||||
|
# has the box (#70 settled on require-down over snapshot-then-export).
|
||||||
|
require_stopped "$inst" "$box" "export wants a settled disk, so the artifact can be trusted later"
|
||||||
|
# Default filename: the box's name plus a UTC stamp. Sortable, never
|
||||||
|
# colliding across repeated exports, and it answers the question you will
|
||||||
|
# actually ask the file six months from now — WHEN is this state from?
|
||||||
|
[ -n "$file" ] || file="$box-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
|
||||||
|
if [ -e "$file" ] && [ "$force" -ne 1 ]; then
|
||||||
|
die "$file already exists — pick another name, or --force to overwrite"
|
||||||
|
fi
|
||||||
|
local extra=()
|
||||||
|
# Snapshots ride along by DEFAULT (#70's call): the reuse workflow lives in
|
||||||
|
# them (log in once, snapshot, clone forever), and an artifact that quietly
|
||||||
|
# dropped the authed checkpoint would defeat its own purpose. --instance-only
|
||||||
|
# is the explicit opt-out, passed through to incus verbatim.
|
||||||
|
[ "$instance_only" -eq 1 ] && extra+=(--instance-only)
|
||||||
|
echo "box: exporting $box → $file ..."
|
||||||
|
incus export "$inst" "$file" "${extra[@]}"
|
||||||
|
echo "box: exported $box → $file"
|
||||||
|
[ "$instance_only" -eq 1 ] || echo "box: (snapshots included — 'box import' brings them back too)"
|
||||||
|
# #70's credential decision: scrub or SHOUT — and box shouts. Scrubbing a
|
||||||
|
# disk image is a promise no tarball surgery can keep (dotfiles, keychains,
|
||||||
|
# tokens in shell history, deleted-but-unwiped blocks); handing someone a
|
||||||
|
# "sanitized" file that is not sanitized would be worse than the risk it
|
||||||
|
# hides. So the artifact carries everything, and box says so every time.
|
||||||
|
{
|
||||||
|
echo "box: ============================== WARNING =============================="
|
||||||
|
echo "box: this file contains the box's ENTIRE disk: agent logins (Claude,"
|
||||||
|
echo "box: Codex, Grok), git credentials, SSH keys, shell history — everything"
|
||||||
|
echo "box: that was inside the box. Nothing in it was scrubbed."
|
||||||
|
echo "box: treat the file itself as a credential: private storage, trusted"
|
||||||
|
echo "box: channels only."
|
||||||
|
echo "box: ====================================================================="
|
||||||
|
} >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# The way back in. Everything 'incus import' restores is the ARTIFACT's truth
|
||||||
|
# (disk, config, devices, snapshots); everything box then re-stamps is THIS
|
||||||
|
# host's truth (the boundary tag, the placement contract, a fresh machine
|
||||||
|
# identity). That split is the design (#70): state is portable, the trust
|
||||||
|
# boundary is not — it is re-established on the current stack, every time.
|
||||||
|
cmd_import() {
|
||||||
|
local file="${args[0]:-}"
|
||||||
|
[ -n "$file" ] || usage_error "usage: $(synopsis_of import)"
|
||||||
|
[ -f "$file" ] || die "no such file: $file"
|
||||||
|
# The artifact names its instance in backup/index.yaml — read it up front:
|
||||||
|
# the collision check and the re-stamping both need the final name BEFORE
|
||||||
|
# incus acts. GNU tar auto-detects the compression on read. '|| true'
|
||||||
|
# because pipefail would otherwise kill the script on a non-tarball with
|
||||||
|
# tar's status instead of reaching the die below that names the problem.
|
||||||
|
local embedded
|
||||||
|
embedded="$(tar -xOf "$file" backup/index.yaml 2>/dev/null | awk '$1 == "name:" { print $2; exit }' || true)"
|
||||||
|
[ -n "$embedded" ] || die "$file is not an incus/box export (no backup/index.yaml inside)"
|
||||||
|
local target="${name:-$embedded}"
|
||||||
|
# The boundary resolve_box enforces, seen from the other side: box will not
|
||||||
|
# occupy a name ANY existing instance holds — not a box's (import is not
|
||||||
|
# restore), and not an unmanaged VM's (not ours to shadow or clobber).
|
||||||
|
if incus config show "$target" >/dev/null 2>&1 </dev/null; then
|
||||||
|
die "an instance named '$target' already exists — import under another name: box import $file --name <new>"
|
||||||
|
fi
|
||||||
|
# The stack this lands on must exist first (a fresh host runs setup-host
|
||||||
|
# before it re-imports) — same pre-flight as a mint, same tier-aware fix.
|
||||||
|
require_stack
|
||||||
|
echo "box: importing $file as $target..."
|
||||||
|
if [ -n "$name" ]; then incus import "$file" "$name"; else incus import "$file"; fi
|
||||||
|
# Re-stamp the boundary tag. user.* keys ride inside the artifact, so a box
|
||||||
|
# export brings its template/user stamps back on its own, and a legacy
|
||||||
|
# user.claudebox=1 stays honored as it is everywhere else. Only an instance
|
||||||
|
# carrying NEITHER tag is stamped user.box=1 now — importing is minting,
|
||||||
|
# and a minted box is ours to manage.
|
||||||
|
local tag
|
||||||
|
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
|
||||||
|
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
|
||||||
|
[ "$tag" = 1 ] || incus config set "$target" user.box=1
|
||||||
|
# Placement: the artifact carries its profile list, but the isolation
|
||||||
|
# contract is THIS host's box-net profile. A box export already says
|
||||||
|
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
|
||||||
|
# re-assigned — the same move migrate-host makes re-homing a legacy box.
|
||||||
|
# (An artifact naming a profile this host lacks fails inside 'incus import'
|
||||||
|
# above, with incus's own error naming the profile — honest enough.)
|
||||||
|
# Two traps in reading the list: a bare name filter PREFIX-matches (asking
|
||||||
|
# for 'work' also returns 'work2'), so anchor it; and the P column joins
|
||||||
|
# multiple profiles with newlines, so join the whole (quoted, multi-line)
|
||||||
|
# cell back into one comparable token instead of trusting the first line.
|
||||||
|
local profs
|
||||||
|
profs="$(incus list "^${target}\$" --format csv --columns P 2>/dev/null | tr -d '" ' | paste -sd, -)"
|
||||||
|
if [ "$profs" != box-net ]; then
|
||||||
|
incus profile assign "$target" box-net
|
||||||
|
echo "box: re-homed onto the box-net profile (the artifact said '${profs:-none}')"
|
||||||
|
fi
|
||||||
|
# The artifact's volatile.* config comes back verbatim too — including the
|
||||||
|
# NIC's MAC address. Importing an artifact twice, or beside the box it was
|
||||||
|
# exported from, then collides at start: "MAC address already defined on
|
||||||
|
# another NIC" (measured live), and the second box is left half-imported.
|
||||||
|
# 'incus copy' regenerates the MAC on clone; import does not — so unset
|
||||||
|
# every volatile hwaddr and let incus mint fresh ones at start. Host-side
|
||||||
|
# identity, the same reasoning as the in-guest machine-id reset below.
|
||||||
|
local k
|
||||||
|
for k in $(incus config show "$target" 2>/dev/null | awk -F: '/^ *volatile\..*\.hwaddr:/ { gsub(/ /, "", $1); print $1 }'); do
|
||||||
|
incus config unset "$target" "$k"
|
||||||
|
done
|
||||||
|
incus start "$target"
|
||||||
|
wait_agent "$target"
|
||||||
|
# The same trust boundary as a clone: the artifact's machine-id rides in
|
||||||
|
# its disk, and the box it was exported from may still exist somewhere. A
|
||||||
|
# fresh identity (machine-id → DHCP client-id → lease) before handover,
|
||||||
|
# always — reset_identity's comment has the collision this prevents.
|
||||||
|
reset_identity "$target"
|
||||||
|
echo "box: imported $target — auth state (agent logins, git creds) came back with it, by design."
|
||||||
|
echo "box: ready — 'box shell $target'."
|
||||||
|
}
|
||||||
|
|
||||||
# The host-health checks live in drill/doctor.sh — grown by the drill, but
|
# The host-health checks live in drill/doctor.sh — grown by the drill, but
|
||||||
# every fault they diagnose (a wedged daemon, a dnsmasq that isn't serving,
|
# every fault they diagnose (a wedged daemon, a dnsmasq that isn't serving,
|
||||||
# a VPN resolver boxes inherit, isolation off in the kernel) is a USER's
|
# a VPN resolver boxes inherit, isolation off in the kernel) is a USER's
|
||||||
|
|
@ -1151,8 +1343,8 @@ die_under_boxes() { # $1 = the act being refused, $2 = the retry command
|
||||||
echo "box: this host has existing boxes:"
|
echo "box: this host has existing boxes:"
|
||||||
while IFS= read -r n; do echo "box: · $n"; done <<<"$names"
|
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: refusing to $1 under them (#66: never change versions under a user's boxes)."
|
||||||
echo "box: preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
|
echo "box: preserve what you care about — 'box down <box>', 'box export <box>' (one"
|
||||||
echo "box: (a portable 'box export' is #70) — then 'box rm <box>' each, and re-run: $2"
|
echo "box: portable file per box, #70) — then 'box rm <box>' each, and re-run: $2"
|
||||||
} >&2
|
} >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,18 @@ snapshots, not a secrets store:
|
||||||
|
|
||||||
Log in once → snapshot → spin up authed boxes from it.
|
Log in once → snapshot → spin up authed boxes from it.
|
||||||
|
|
||||||
|
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
|
||||||
|
clone still lives on the same host. The off-host mechanism is `box export` /
|
||||||
|
`box import` (#70) — one portable backup tarball, snapshots included by
|
||||||
|
default, that survives `rm`, a host teardown, an upgrade, a move. The split
|
||||||
|
of truths is the design: everything `incus import` restores is the artifact's
|
||||||
|
(disk, config, snapshots); everything box re-stamps on import is the current
|
||||||
|
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
|
||||||
|
machine identity via the same `reset_identity` a clone gets). Auth state
|
||||||
|
rides along deliberately — and because scrubbing a disk image is a promise
|
||||||
|
tarball surgery cannot keep, export shouts that the file is a credential
|
||||||
|
instead of pretending to sanitize it.
|
||||||
|
|
||||||
## The box announces itself to the agent
|
## The box announces itself to the agent
|
||||||
|
|
||||||
cloud-init installs a global agent-context file in every coding-agent box
|
cloud-init installs a global agent-context file in every coding-agent box
|
||||||
|
|
|
||||||
134
docs/plans/2026-07-18-box-export.md
Normal file
134
docs/plans/2026-07-18-box-export.md
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
# box export / import — a box's state that survives the box and the host (#70)
|
||||||
|
|
||||||
|
**Status: implemented.** Asked for by @danmt on #66, as the prerequisite for
|
||||||
|
the upgrade flow the installer wants to enforce: _stop, export, remove every
|
||||||
|
box before you upgrade — then re-import_. This doc records the design
|
||||||
|
decisions, the facts they rest on, and what holds them.
|
||||||
|
|
||||||
|
## The gap #70 named
|
||||||
|
|
||||||
|
Nothing a box held could outlive a host teardown:
|
||||||
|
|
||||||
|
- `box snapshot` is an **in-box** checkpoint, and `box rm` deletes the box
|
||||||
|
and every snapshot it has — a snapshot cannot outlive its box.
|
||||||
|
- `box new --from` clones to an independent box, but the clone still lives
|
||||||
|
**on the same host**, under the same stack. It is not an artifact you can
|
||||||
|
carry off the machine or keep across a teardown.
|
||||||
|
|
||||||
|
So #66's refusal-to-upgrade-over-live-boxes could only say "copy things out
|
||||||
|
by hand" — honest, but lossy. `box export` upgrades that instruction to
|
||||||
|
"export, keep the file, re-import after".
|
||||||
|
|
||||||
|
## The shape
|
||||||
|
|
||||||
|
`incus export` / `incus import` are the primitives: a backup tarball of an
|
||||||
|
instance and (by default) its snapshots, and instance creation from that
|
||||||
|
tarball. box wraps them where it must enforce what incus cannot see.
|
||||||
|
|
||||||
|
- **`box export <box> [<file>]`** — the box must be tagged `user.box=1` (the
|
||||||
|
boundary, as everywhere) and **stopped**. Default filename
|
||||||
|
`<box>-<UTC stamp>.tar.gz` (sortable, collision-free, and it answers the
|
||||||
|
question you will ask the file later: _when is this state from?_).
|
||||||
|
Snapshots ride along by default; `--instance-only` opts out, passed to
|
||||||
|
incus verbatim. Refuses to overwrite an existing file without `--force`.
|
||||||
|
- **`box import <file> [--name <box>]`** — reads the artifact's own instance
|
||||||
|
name from `backup/index.yaml` up front, refuses any name an existing
|
||||||
|
instance already holds (box or not — `resolve_box`'s boundary from the
|
||||||
|
other side), pre-flights the stack (`require_stack`, factored out of
|
||||||
|
`cmd_new` now that it has two callers), imports, then re-stamps, starts,
|
||||||
|
and hands over.
|
||||||
|
|
||||||
|
## The three decisions, and why they fell where they did
|
||||||
|
|
||||||
|
**1. Require `box down` first — no live export, no snapshot-then-export.**
|
||||||
|
Incus _can_ back up a running instance, but a live root disk is a moving
|
||||||
|
target, and this artifact's entire job is to be trusted later, on a host
|
||||||
|
that no longer has the box to compare against. The refusal reuses
|
||||||
|
`require_stopped` with an honest reason parameter: rename is stopped because
|
||||||
|
_incus_ insists; export is stopped because _we_ decided — the message should
|
||||||
|
not claim otherwise.
|
||||||
|
|
||||||
|
**2. Snapshots included by default.** box's reuse workflow (log in once,
|
||||||
|
snapshot, clone forever) lives in snapshots; an artifact that quietly
|
||||||
|
dropped the authed checkpoint would defeat the verb's purpose. The opt-out
|
||||||
|
is explicit and named for what it does (`--instance-only`).
|
||||||
|
|
||||||
|
**3. Credentials: shout, don't scrub.** A box's disk carries agent logins,
|
||||||
|
git PATs, SSH keys, shell history, deleted-but-unwiped blocks. "Scrubbing" a
|
||||||
|
disk-image tarball is a promise no tarball surgery can keep, and handing
|
||||||
|
someone a file labeled sanitized that is not would be worse than the risk it
|
||||||
|
hides. So export prints a loud, unconditional stderr warning: the file _is_
|
||||||
|
a credential; store and move it as one. Import repeats the point — auth
|
||||||
|
state came back by design, the same trust boundary as cloning an authed
|
||||||
|
snapshot.
|
||||||
|
|
||||||
|
## Import re-stamps the host's truth, not the artifact's
|
||||||
|
|
||||||
|
The split is the design. Everything `incus import` restores is the
|
||||||
|
artifact's truth: disk, config, devices, snapshots. Everything box then
|
||||||
|
re-stamps is the current host's:
|
||||||
|
|
||||||
|
- **The boundary tag.** `user.*` keys ride inside the artifact, so a box
|
||||||
|
export brings `user.box.template` / `user.box.user` back on its own, and a
|
||||||
|
legacy `user.claudebox=1` stays honored as it is everywhere else. An
|
||||||
|
instance carrying neither tag is stamped `user.box=1` — importing is
|
||||||
|
minting, and a minted box is ours to manage.
|
||||||
|
- **The placement.** The artifact carries its profile list, but the
|
||||||
|
isolation contract is _this_ host's `box-net` profile. A box export
|
||||||
|
already says `box-net`; anything else is re-assigned (`incus profile
|
||||||
|
assign` — the same move `migrate-host` makes re-homing a legacy box). An
|
||||||
|
artifact naming a profile the host lacks fails inside `incus import` with
|
||||||
|
incus's own error naming it. A fresh host without the stack at all is
|
||||||
|
refused before the import, tier-aware (`require_stack`: admins are sent to
|
||||||
|
`setup-host`, restricted users to `box grant`).
|
||||||
|
- **The identity — host side and guest side.** The artifact's `volatile.*`
|
||||||
|
config comes back verbatim, _including the NIC's MAC_: importing an
|
||||||
|
artifact twice, or beside the box it was exported from, collided at start
|
||||||
|
with `MAC address already defined on another NIC` (measured live on Incus
|
||||||
|
6.0.4 — `incus copy` regenerates the MAC on clone; `incus import` does
|
||||||
|
not). So import unsets every volatile hwaddr before the start and lets
|
||||||
|
incus mint fresh ones. Then, in-guest: the artifact's machine-id rides in
|
||||||
|
its disk, and `reset_identity` runs before handover, exactly like a clone
|
||||||
|
— machine-id → DHCP client-id → lease, the collision that function's
|
||||||
|
comment documents. Verified live: two imports of one artifact running side
|
||||||
|
by side with distinct MACs, distinct machine-ids, both holding the
|
||||||
|
pre-export file and snapshot.
|
||||||
|
|
||||||
|
## The restricted tier: measured, then converged
|
||||||
|
|
||||||
|
`incus export` rides the backup API (an export _is_ "create a backup,
|
||||||
|
download it, delete it"), and a restricted project blocks it by default:
|
||||||
|
`restricted.backups=block` the moment `restricted=true` — read from incus
|
||||||
|
6.0's `internal/server/project/permissions.go` (the default table, and
|
||||||
|
`AllowBackupCreation` enforcing it). Import needs no key of its own:
|
||||||
|
restoring a backup file is plain instance creation.
|
||||||
|
|
||||||
|
So the honest answer was not an `expose`-style refusal — the limitation is a
|
||||||
|
project key, not daemon-global state — but the same convergence grant
|
||||||
|
already performs for snapshots: `box grant` now also sets
|
||||||
|
`restricted.backups allow`. Re-run `box grant <user>` after upgrading, as
|
||||||
|
the grant contract already says.
|
||||||
|
|
||||||
|
## What holds it
|
||||||
|
|
||||||
|
- **`test/cli.sh`** (dependency-free, no incus): driven usage errors
|
||||||
|
(missing box/file/name-value, unknown box, missing file, a non-artifact
|
||||||
|
file refused by the pure tar+awk parse), and grep/line-order guards for
|
||||||
|
every daemon-gated invariant — `require_stopped` before `incus export`,
|
||||||
|
snapshots-by-default, the credential shout, `user.box=1` re-stamping, the
|
||||||
|
collision guard before `incus import`, `require_stack` in both `cmd_new`
|
||||||
|
and `cmd_import`, `reset_identity` after the start, and grant's
|
||||||
|
`restricted.backups allow`. All fail-closed: a deleted guard cannot ship
|
||||||
|
green.
|
||||||
|
- **CI's `rehearsal` job**, on the runner's live Incus (container mode — the
|
||||||
|
round-trip is backup mechanics, identical across instance types; the VM
|
||||||
|
boundary stays a real-hardware ritual): mint → write a file → snapshot →
|
||||||
|
down → export → `rm` → import under a new name → assert the tag, the
|
||||||
|
agent, the file, the snapshot survived, and that a colliding re-import is
|
||||||
|
refused.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- #66 — the installer refusal this makes humane (its message is reconciled
|
||||||
|
when both land).
|
||||||
|
- #67 — the version-aware upgrade that would migrate instead of asking.
|
||||||
|
|
@ -151,6 +151,13 @@ snaps="$(incus project get "$p1" restricted.snapshots 2>/dev/null)"
|
||||||
[ "$snaps" = allow ] && ok "snapshots allowed in $p1 (the clone workflow exists)" \
|
[ "$snaps" = allow ] && ok "snapshots allowed in $p1 (the clone workflow exists)" \
|
||||||
|| no "restricted.snapshots = '$snaps' — box snapshot will refuse"
|
|| no "restricted.snapshots = '$snaps' — box snapshot will refuse"
|
||||||
|
|
||||||
|
# The same shape for backups (#70): export rides the backup API, which
|
||||||
|
# restricted projects block by default exactly like snapshots. A grant that
|
||||||
|
# missed this key strands every post-upgrade 'box export' at the tier.
|
||||||
|
bkups="$(incus project get "$p1" restricted.backups 2>/dev/null)"
|
||||||
|
[ "$bkups" = allow ] && ok "backups allowed in $p1 (box export works at this tier)" \
|
||||||
|
|| no "restricted.backups = '$bkups' — box export will refuse"
|
||||||
|
|
||||||
incus --project "$p1" profile device get default eth0 type >/dev/null 2>&1 \
|
incus --project "$p1" profile device get default eth0 type >/dev/null 2>&1 \
|
||||||
&& no "(h) $p1's default profile still carries the private-bridge eth0" \
|
&& no "(h) $p1's default profile still carries the private-bridge eth0" \
|
||||||
|| ok "(h) $p1's default profile places no network — box-net is the only door"
|
|| ok "(h) $p1's default profile places no network — box-net is the only door"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
# '--network' flag away from any box they mint
|
# '--network' flag away from any box they mint
|
||||||
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
|
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
|
||||||
# on them)
|
# on them)
|
||||||
# 6. install the shipped box-net profile into their project
|
# 6. allow backups (blocked too; 'box export' rides the backup API — #70)
|
||||||
|
# 7. install the shipped box-net profile into their project
|
||||||
#
|
#
|
||||||
# Idempotent: every step converges, so re-running (including after a box
|
# Idempotent: every step converges, so re-running (including after a box
|
||||||
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
|
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
|
||||||
|
|
@ -183,7 +184,18 @@ echo "network: $project restricted to boxnet (the private $bridge is unreference
|
||||||
incus project set "$project" restricted.snapshots allow </dev/null
|
incus project set "$project" restricted.snapshots allow </dev/null
|
||||||
echo "snapshots: allowed"
|
echo "snapshots: allowed"
|
||||||
|
|
||||||
# 6. The placement contract itself, installed into their project. Created if
|
# 6. Backups. 'box export' rides incus's backup API — an export IS "create a
|
||||||
|
# backup, download it, delete it" — and a restricted project blocks that by
|
||||||
|
# default: restricted.backups=block the moment restricted=true (incus 6.0,
|
||||||
|
# internal/server/project/permissions.go, enforced by AllowBackupCreation).
|
||||||
|
# 'box import' needs no key of its own — restoring a backup file is plain
|
||||||
|
# instance creation. Same convergence as snapshots, for the same reason: the
|
||||||
|
# tier is the same workflows on your own boxes, and export/import are
|
||||||
|
# workflows (#70).
|
||||||
|
incus project set "$project" restricted.backups allow </dev/null
|
||||||
|
echo "backups: allowed ('box export' rides them, #70)"
|
||||||
|
|
||||||
|
# 7. The placement contract itself, installed into their project. Created if
|
||||||
# missing, refreshed unconditionally — same convergence discipline as
|
# missing, refreshed unconditionally — same convergence discipline as
|
||||||
# setup-host's own profile handling, so a box upgrade propagates by re-run.
|
# setup-host's own profile handling, so a box upgrade propagates by re-run.
|
||||||
incus --project "$project" profile show box-net >/dev/null 2>&1 </dev/null \
|
incus --project "$project" profile show box-net >/dev/null 2>&1 </dev/null \
|
||||||
|
|
|
||||||
|
|
@ -268,8 +268,8 @@ else
|
||||||
while IFS= read -r n; do warn " · $n"; done <<<"$names"
|
while IFS= read -r n; do warn " · $n"; done <<<"$names"
|
||||||
warn "refusing to change the default box version under them (#66) — the default stays at $old_ver."
|
warn "refusing to change the default box version under them (#66) — the default stays at $old_ver."
|
||||||
log "box $new_ver is installed side-by-side. To switch:"
|
log "box $new_ver is installed side-by-side. To switch:"
|
||||||
log " · preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
|
log " · preserve what you care about — 'box down <box>', 'box export <box>'"
|
||||||
log " (a portable 'box export' is #70), then 'box rm <box>' when you are done"
|
log " (one portable file per box, #70), then 'box rm <box>' when you are done"
|
||||||
log " · then flip the default: box use $new_ver"
|
log " · then flip the default: box use $new_ver"
|
||||||
else
|
else
|
||||||
flip_current "$new_ver"
|
flip_current "$new_ver"
|
||||||
|
|
|
||||||
82
test/cli.sh
82
test/cli.sh
|
|
@ -195,11 +195,15 @@ check "expose: the restricted guard precedes the first incus call" 0 "" bash -c
|
||||||
[ -n "$guard" ] && [ -n "$first" ] && [ "$guard" -lt "$first" ]'
|
[ -n "$guard" ] && [ -n "$first" ] && [ "$guard" -lt "$first" ]'
|
||||||
# cmd_new refuses before minting when the placement contract is absent, and
|
# cmd_new refuses before minting when the placement contract is absent, and
|
||||||
# the message is tier-aware (a restricted user is sent to 'box grant', not
|
# the message is tier-aware (a restricted user is sent to 'box grant', not
|
||||||
# to setup-host they cannot run).
|
# to setup-host they cannot run). The pre-flight lives in require_stack()
|
||||||
check "new: pre-flights the box-net profile" 0 "" bash -c '
|
# since #70 gave it a second caller (import lands on the same contract), so
|
||||||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"'
|
# assert both halves: the helper holds the probe, and cmd_new calls it.
|
||||||
check "new: the restricted fix names box grant" 0 "" bash -c '
|
check "require_stack: probes the box-net profile" 0 "" bash -c '
|
||||||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"'
|
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"'
|
||||||
|
check "require_stack: the restricted fix names box grant" 0 "" bash -c '
|
||||||
|
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"'
|
||||||
|
check "new: pre-flights the stack (require_stack)" 0 "" bash -c '
|
||||||
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
|
||||||
# grant converges to boxnet and ONLY boxnet — "boxnet,incusbr" would keep the
|
# grant converges to boxnet and ONLY boxnet — "boxnet,incusbr" would keep the
|
||||||
# unhardened private bridge one --network flag away (the #74 measured hole).
|
# unhardened private bridge one --network flag away (the #74 measured hole).
|
||||||
check "grant: narrows access to boxnet alone" 0 "" \
|
check "grant: narrows access to boxnet alone" 0 "" \
|
||||||
|
|
@ -270,6 +274,74 @@ check "box exports BOX_TIER to the doctor" 0 "" \
|
||||||
# does not exist and the verb was broken for everyone until #74's rehearsal hit it.
|
# does not exist and the verb was broken for everyone until #74's rehearsal hit it.
|
||||||
check "restore: dispatches 'incus snapshot restore'" 0 "" \
|
check "restore: dispatches 'incus snapshot restore'" 0 "" \
|
||||||
grep -qF '^incus:snapshot restore^' "$ROOT/bin/box"
|
grep -qF '^incus:snapshot restore^' "$ROOT/bin/box"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# export / import (#70) — a box's state that survives the box and the host.
|
||||||
|
# Usage errors and the pure pre-incus refusals are DRIVEN; every daemon-gated
|
||||||
|
# invariant is grep-guarded or line-order-asserted (fail-closed: an empty
|
||||||
|
# grep is a FAIL, so a deleted guard cannot ship green).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
check "export without a box exits 2" 2 "usage: box export" "$BOX" export
|
||||||
|
check "export of an unknown box exits 1" 1 "no such box" "$BOX" export nosuchbox
|
||||||
|
check "import without a file exits 2" 2 "usage: box import" "$BOX" import
|
||||||
|
check "import of a missing file exits 1" 1 "no such file" "$BOX" import /nope/nothing.tar.gz
|
||||||
|
check "import --name with no value exits 2" 2 "--name needs a value" "$BOX" import x.tar.gz --name
|
||||||
|
# A file that is not an export artifact is named as such, before any incus
|
||||||
|
# call — pure (tar + awk), so it is driven, not grepped.
|
||||||
|
NOTATARBALL="$(mktemp)"; echo "not a tarball" > "$NOTATARBALL"
|
||||||
|
check "import: a non-artifact file is refused" 1 "not an incus/box export" "$BOX" import "$NOTATARBALL"
|
||||||
|
rm -f "$NOTATARBALL"
|
||||||
|
check "help export names the credential risk" 0 "CREDENTIAL" "$BOX" help export
|
||||||
|
check "help import names the re-stamping" 0 "user.box=1" "$BOX" help import
|
||||||
|
# Export refuses a running box — require_stopped fires BEFORE incus export
|
||||||
|
# (line order inside cmd_export, fail-closed on either grep missing).
|
||||||
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||||||
|
check "export: requires the box stopped, before exporting" 0 "" bash -c '
|
||||||
|
fn="$(awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||||||
|
guard="$(printf "%s\n" "$fn" | grep -n "require_stopped" | head -1 | cut -d: -f1)"
|
||||||
|
run="$(printf "%s\n" "$fn" | grep -n "incus export" | head -1 | cut -d: -f1)"
|
||||||
|
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
|
||||||
|
# Snapshots ride along by default; --instance-only is the explicit opt-out.
|
||||||
|
check "export: snapshots included unless --instance-only" 0 "" bash -c '
|
||||||
|
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q -- "--instance-only"'
|
||||||
|
# The credential SHOUT (#70's scrub-or-shout decision: box shouts).
|
||||||
|
check "export: shouts that the file is a credential" 0 "" bash -c '
|
||||||
|
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "treat the file itself as a credential"'
|
||||||
|
# Import re-stamps the boundary tag onto the current stack.
|
||||||
|
check "import: re-stamps user.box=1" 0 "" bash -c '
|
||||||
|
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "user.box=1"'
|
||||||
|
# The name-collision guard fires BEFORE incus import — the resolve_box
|
||||||
|
# boundary from the other side: never occupy an existing instance's name.
|
||||||
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||||||
|
check "import: the collision guard precedes the import" 0 "" bash -c '
|
||||||
|
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||||||
|
guard="$(printf "%s\n" "$fn" | grep -n "already exists" | head -1 | cut -d: -f1)"
|
||||||
|
run="$(printf "%s\n" "$fn" | grep -n "incus import" | head -1 | cut -d: -f1)"
|
||||||
|
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
|
||||||
|
# Import lands on the placement contract: same pre-flight as a mint.
|
||||||
|
check "import: pre-flights the stack (require_stack)" 0 "" bash -c '
|
||||||
|
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
|
||||||
|
# The artifact's MAC comes back verbatim, and a re-import beside a sibling
|
||||||
|
# collides at start (measured live: "MAC address already defined on another
|
||||||
|
# NIC") — the hwaddr unset must precede the start. Line order, fail-closed.
|
||||||
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||||||
|
check "import: regenerates the NIC MAC before the start" 0 "" bash -c '
|
||||||
|
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||||||
|
mac="$(printf "%s\n" "$fn" | grep -n "hwaddr" | head -1 | cut -d: -f1)"
|
||||||
|
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
|
||||||
|
[ -n "$mac" ] && [ -n "$start" ] && [ "$mac" -lt "$start" ]'
|
||||||
|
# reset_identity runs AFTER the imported box is started — the clone trust
|
||||||
|
# boundary (machine-id → DHCP lease), line-order-asserted, fail-closed.
|
||||||
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
||||||
|
check "import: reset_identity follows the start" 0 "" bash -c '
|
||||||
|
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||||||
|
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
|
||||||
|
reset="$(printf "%s\n" "$fn" | grep -n "reset_identity" | head -1 | cut -d: -f1)"
|
||||||
|
[ -n "$start" ] && [ -n "$reset" ] && [ "$start" -lt "$reset" ]'
|
||||||
|
# The restricted tier can export: grant converges restricted.backups (the
|
||||||
|
# backup API is what 'incus export' rides; blocked by default — #70).
|
||||||
|
check "grant: allows backups (the export workflow)" 0 "" \
|
||||||
|
grep -qF 'restricted.backups allow' "$ROOT/host/grant-user.sh"
|
||||||
|
|
||||||
# The rehearsal itself stays runnable: syntax-checked here, run on real hosts.
|
# The rehearsal itself stays runnable: syntax-checked here, run on real hosts.
|
||||||
check "multiuser.sh is valid bash" 0 "" bash -n "$ROOT/drill/multiuser.sh"
|
check "multiuser.sh is valid bash" 0 "" bash -n "$ROOT/drill/multiuser.sh"
|
||||||
check "multiuser.sh refuses without the env gate" 2 "opt in" \
|
check "multiuser.sh refuses without the env gate" 2 "opt in" \
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue