feat: box export / import — state that survives the box and the host (#70) #78
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#78
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/box-export"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements #70 — the export @danmt asked for on #66, and the prerequisite for the upgrade flow the installer wants to enforce: down → export → rm → upgrade → re-import.
The artifact contract
box export <box> [<file>]wrapsincus exportinto one backup tarball (default<box>-<UTC stamp>.tar.gz) that survivesbox rm, a host teardown, and a move to another machine.box import <file> [--name <box>]mints the box back on any host with the stack.--instance-onlyis the explicit opt-out, passed to incus verbatim.require_stopped, grown an honest reason parameter — export is stopped by our decision for a settled disk, not by incus's necessity, and the refusal says so).Import re-stamps the host's truth, not the artifact's
Everything
incus importrestores is the artifact's (disk, config, snapshots). What box re-stamps is the current host's:user.box=1(legacyuser.claudebox=1honored, as everywhere), and the name must be free — import refuses to occupy any existing instance's name, box or not (theresolve_boxboundary from the other side).box-netplacement — re-assigned when the artifact's profile list differs (themigrate-hostmove). A fresh host without the stack is refused tier-aware before the import (require_stack, factored out ofcmd_new).volatile.*verbatim including the NIC MAC — a re-import beside its sibling collided at start withMAC address already defined on another NIC(measured live;incus copyregenerates it,incus importdoes not), so import unsets volatile hwaddrs before start — thenreset_identity, exactly like a clone.Tiers
Export rides the backup API, which incus-user's restricted projects block by default (
restricted.backups=block, incus 6.0permissions.go/AllowBackupCreation) — the same shape as the snapshots block #74 already converged. Sobox grantnow also setsrestricted.backups allow; import is plain instance creation and needs no key. Re-runbox grant <user>after upgrading, as the grant contract already documents.Verification
test/cli.sh: 100/100 — driven usage errors (missing box/file/name-value, unknown box, missing file, non-artifact file refused by the pure tar+awk parse) plus fail-closed grep/line-order guards: stopped-before-export, snapshots-by-default, the credential shout,user.box=1re-stamp, collision-guard-before-import,require_stackin both callers, MAC-unset-before-start,reset_identity-after-start, grant'srestricted.backups allow.rehearsaljob grew a live round-trip on the runner's Incus (container mode): mint → write a file → snapshot → down → export →rm→ import under a new name → assert the tag, the agent, the file, the snapshot, and the collision refusal.rm, import with and without--name, re-home ontobox-net, sibling re-imports running side by side with distinct MACs and machine-ids, pre-export file and snapshot present in both.shellcheck -x(globstar) clean;install.shdeliberately untouched — its "#70 is not done yet" upgrade message is owned by a concurrent PR and reconciles at rebase time.Docs: README (export/import section + the upgrade-flow story),
docs/box-design.mdamendment,docs/plans/2026-07-18-box-export.md, CHANGELOG (Unreleased).Closes #70
🤖 Generated with Claude Code
Merge order (cross-repo)
Analyzed all open heavy-duty PRs (box #79/#78/#69, rig #30/#29/#21/#19) for file overlap, semantic dependencies, and mergeability. The recommended order:
test/cli.sh→ trivial rebase after #29.install.shrewrite,bin/box, CI). Lands first so the other box PRs rebase onto the new layout exactly once.install.shuntouched ("owned by a concurrent PR, reconciles at rebase time" — that PR is #79). Rebasing after #79 lets the installer's upgrade-flow message finally namedown → export → rm → upgrade → re-import.bin/box/test/cli.sh/CHANGELOG/docs/box-design.md→ mechanical rebase.Closed as superseded (both predate the traits redesign that landed via rig#27/#28, and their content is re-delivered on the new design by their successors' own account):
Notes:
This PR is step 4 — rebase onto #79: reconcile
install.sh's upgrade message with the export verbs (per this PR's own rebase note), plus thebin/boxCMDS table, CI, CHANGELOG andtest/cli.sh.Merge-order update: box#69 was closed in favor of the thin-template layering (#81, heavy-duty/rig#31); its surviving mint-time pieces were re-cut as #82. The box track is now #79 → #78 → #82.
✅ Reviewed — I agree with all of this, no concerns.
Read the full diff, not just the prose. The design holds up end to end:
cmd_importordering is correct where it matters — embedded-name read → collision guard (incus config show "$target") →require_stack→ import → tag re-stamp → profile re-home → volatile hwaddr unset beforeincus start→reset_identityafter. The MAC-collision fix is real and the guards are placed to actually prevent it.require_stoppedgrowing an honestwhyparameter (bin/box:628) is the right call over a bare reuse; the refusal now tells the truth per caller.existing/collision check refusing any instance's name (box or not) is the correctresolve_boxmirror.grant'srestricted.backups allowis justified against the backup-API gating and lands next to the snapshot grant it mirrors.box rm.One thing I verified rather than flag:
import'sbackup/index.yamlparse takes the firstname:line viaawk … exit, which is correct for a genuine incus artifact (top-levelname:leads), and a hand-rolled file that violates that is already covered by the collision/incus importfailure paths. No change needed.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve
Solid implementation of #70. I read the full diff, prior issue body, CI (check + rehearsal both green on
9bb3315), and the existing review/comments. No prior review from this account. Design decisions (down-first, snapshots-by-default, shout-not-scrub, host-side re-stamp) are coherent, measured, and held by both structural tests and a live round-trip.What holds
cmd_export(bin/box):require_stoppedwith an honest reason beforeincus export; default<box>-<UTC>.tar.gz; overwrite guard via--force;--instance-onlypassed through; unconditional credential warning on stderr. Correct boundary (pre=box→resolve_box).cmd_importordering is right where it matters:backup/index.yamlname read → collision on any existing instance →require_stack→ import → tag re-stamp (legacyuser.claudebox=1honored) → profile re-home tobox-netwhen needed → volatile*.hwaddrunset beforeincus start→wait_agent→reset_identity. The MAC fix matches a real Incus import quirk (incus copyregenerates; import does not).require_stackfactored out ofcmd_newfor the second caller without changing the tier-aware diagnosis (wedged daemon vs missing profile / grant).host/grant-user.sh:restricted.backups allowis the right mirror of the snapshots grant; export is backup-API-shaped, import is plain create.rm→ import → file/snap/tag/agent + collision refusal.Nits (non-blocking)
--forceon export is under-documented.cmd_exportrefuses an existing file unless--force(bin/box~1103–1104), and the die text names it — but OPTIONS still describes--forceas only forrm, andhelp exportnever mentions overwrite. Either list it under export in OPTIONS /help export, or drop--forceand always require a new path. Prefer documenting.drill/multiuser.shassertsrestricted.snapshots allowafter grant, notrestricted.backups allow.test/cli.shgreps the grant script; the live restricted-tier rehearsal does not prove the new key. One-line parallel check next to the snapshots assertion would close the gap for post-upgrade “re-runbox grant” users.install.shstill says export is “#70; for now copy out by hand” (main). Intentional per PR body / merge-order note (#79 first), but if this lands alone main will contradict itself (README already teachesexport). Worth a one-line install message update here if #79 might lag; otherwise keep the documented order.Cosmetic:
reset_identitystill prints “giving the clone its own identity…” when import calls it. Harmless; a neutral “instance” wording would match both callers.Out of scope / agreed deferrals
install.shchange in this PR (owned by #79).No blockers. Approve.
Verdict: I agree with everything and have no additional feedback.
The export/import ordering preserves the intended boundaries: stopped export, collision check before import, current-host tag/profile re-stamping, volatile MAC regeneration before start, and guest identity reset afterward. The credential warning is explicit, snapshots remain included by default, and the live round-trip checks are green.
Round 1 response — thanks all three for the approvals. @grok-bot-andresmgsl's nits were cheap and worth taking now rather than at rebase time; done in 38b129b:
help exportlists--forcebeside--instance-only.restricted.backups = allowright beside the snapshots check it mirrors — the grant key #70 depends on is proven live, not only grepped.Suite: 100/100, shellcheck clean. Over to @danmt for final review (after #79 lands, per the merge order).
Rebased onto merged #79 (
b077279) — conflicts in CI/CHANGELOG/README resolved, suite 181/181, shellcheck clean. This includes the reconciliation both PR bodies promised: the installer's and the #66 refusals' "copy out by hand (a portable export is #70)" placeholders now name the real flow — down → export → rm → switch → import. CI keeps main's versioned layout + uninstall drill, with the #70 round-trip step ordered before the uninstall.