forked from heavy-duty/box
2550 lines
133 KiB
Bash
Executable file
2550 lines
133 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# box — trust-less, isolated Incus VMs with Claude Code, creds-free.
|
|
# The command surface is the CMDS table below: it is the single source of truth
|
|
# for what exists, what it looks like, what the help says, and what runs. The
|
|
# help cannot drift from the code, because it is rendered from the same rows.
|
|
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=""; instance_only=0
|
|
inst="" # the resolved Incus instance, set by the 'box' precondition
|
|
# Labels snapshot_mark actually CREATED this run, space-separated. Every path
|
|
# through snapshot_mark returns 0 on purpose — the never-fatal contract — so
|
|
# the exit status cannot tell a mark that was taken from one that was skipped
|
|
# (dir pool, knob=0) or refused (incus said no). Anything that offers the
|
|
# operator a mark must ask this, not assume the call happened.
|
|
marks=""
|
|
|
|
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
|
|
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
|
|
# The tree's own version, read in ONE place. 'box --version' says it out loud;
|
|
# the mint stamp (#103) writes it onto every instance box creates, so a box can
|
|
# still name the release that made it long after that release is history.
|
|
box_version() { cat "$root/VERSION" 2>/dev/null || echo unknown; }
|
|
version() { echo "box $(box_version) ($root)"; }
|
|
|
|
# The SHAPE of the mint stamp, not the box version — an integer that changes
|
|
# only when a key is removed or repurposed, never when one is added (a reader
|
|
# that does not know a key simply does not print it). Absent means pre-stamp:
|
|
# every box minted before #103 has no schema key at all, and must keep working
|
|
# under every verb, which is the same promise 'user.claudebox' carries.
|
|
BOX_STAMP_SCHEMA=1
|
|
|
|
# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
|
|
# what the kernel will present when incus opens the socket), never from the
|
|
# group database — the two disagree for exactly as long as a re-login is
|
|
# pending, and that window is where every wrong answer lives.
|
|
# UID 0 / incus-admin -> admin (the full daemon socket)
|
|
# incus (only) -> restricted (incus-user: your own project, nothing else)
|
|
# neither -> none (no socket at all)
|
|
# host/setup-host.sh carries a byte-identical copy (it runs before any install
|
|
# tree exists); test/cli.sh diffs the two so they cannot drift.
|
|
box_tier() {
|
|
[ "$(id -u)" -eq 0 ] && { printf 'admin\n'; return; }
|
|
local groups; groups="$(id -nG 2>/dev/null | tr ' ' '\n')"
|
|
if printf '%s\n' "$groups" | grep -qx incus-admin; then printf 'admin\n'
|
|
elif printf '%s\n' "$groups" | grep -qx incus; then printf 'restricted\n'
|
|
else printf 'none\n'
|
|
fi
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The command table.
|
|
#
|
|
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message ^ confirm prompt
|
|
#
|
|
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
|
|
#
|
|
# preconditions (comma-separated):
|
|
# box first positional is a box: resolve it, and REFUSE if the instance
|
|
# isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
|
|
# arg2 a second positional is required
|
|
# stopped the box must not be running
|
|
# confirm destructive: prompt unless --force. The row supplies the words
|
|
# (last field) — see 'confirm prompt' below.
|
|
#
|
|
# action:
|
|
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
|
|
# fn:<function> call a shell function (it has real work to do)
|
|
#
|
|
# ok message: printed on success; {} = the box, {1} = the second positional.
|
|
#
|
|
# confirm prompt: the words the 'confirm' precondition asks with, phrased as
|
|
# the act ("delete X", "roll X back to Y") — confirm() wraps it into
|
|
# "box: <prompt>? this cannot be undone." and into the no-TTY refusal
|
|
# ("refusing to <prompt> without --force"). It is a per-row field and not a
|
|
# shared string on purpose: the prompt was hardcoded to rm's wording once, and
|
|
# the whole reason 'restore' shipped ungated for four releases is that adding
|
|
# the token to its row would have asked the operator to confirm DELETING the
|
|
# box they were trying to rescue (#105). A gate that names the wrong act is
|
|
# worse than no gate — it teaches people to answer 'y' without reading. Same
|
|
# substitutions as the ok message, except {} is the RESOLVED instance: the
|
|
# prompt names the thing about to be destroyed, so under --remote it should
|
|
# say 'lab:work', not 'work'.
|
|
#
|
|
# Adding a thin verb is one row. If a request can't be expressed as a row and
|
|
# doesn't enforce a box invariant, it is incus's job, not ours — that is
|
|
# what `box incus` is for.
|
|
CMDS=(
|
|
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
|
|
"templates^^^List the templates this install can mint^fn:cmd_templates^"
|
|
"list^[--json]^^List your boxes^fn:cmd_list^"
|
|
"info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
|
|
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
|
|
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
|
|
"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^"
|
|
"restore^<box> <snapshot> [--force]^box,arg2,confirm^Roll a box back to one of its snapshots — irreversible, and it asks first^incus:snapshot restore^restored {} to {1}^roll {} back to snapshot '{1}' and discard everything in the box since it was taken"
|
|
"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}"
|
|
"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 {}"
|
|
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}^delete {} and all its snapshots"
|
|
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
|
|
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
|
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
|
|
"setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
|
|
"grant^<user>^^Admin: give a host user the restricted tier — their own boxes, on the hardened boxnet^fn:cmd_grant^"
|
|
"revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^"
|
|
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
|
|
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
|
|
"versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^"
|
|
"use^<version>^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^"
|
|
"uninstall^[<version>] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^"
|
|
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
|
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
|
|
)
|
|
|
|
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
|
|
verbs() { local r; for r in "${CMDS[@]}"; do echo "${r%%^*}"; done; }
|
|
is_command() { cmd_row "$1" >/dev/null 2>&1; }
|
|
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
|
|
# error paths inside it — a global read would clobber the row being dispatched.
|
|
field() {
|
|
local r f_syn f_pre f_sum f_act f_ok f_cnf
|
|
r="$(cmd_row "$1")" || return 1
|
|
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok f_cnf <<<"$r"
|
|
case "$2" in
|
|
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
|
|
act) echo "$f_act" ;; ok) echo "$f_ok" ;; cnf) echo "$f_cnf" ;;
|
|
esac
|
|
}
|
|
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
|
|
|
|
# Nearest command by edit distance — a typo should point somewhere, not just fail.
|
|
suggest() {
|
|
verbs | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
|
|
function dist(a, b, la, lb, i, j, c, prev, cur) {
|
|
la = length(a); lb = length(b)
|
|
for (j = 0; j <= lb; j++) prev[j] = j
|
|
for (i = 1; i <= la; i++) {
|
|
cur[0] = i
|
|
for (j = 1; j <= lb; j++) {
|
|
c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
|
|
cur[j] = prev[j] + 1
|
|
if (cur[j - 1] + 1 < cur[j]) cur[j] = cur[j - 1] + 1
|
|
if (prev[j - 1] + c < cur[j]) cur[j] = prev[j - 1] + c
|
|
}
|
|
for (j = 0; j <= lb; j++) prev[j] = cur[j]
|
|
}
|
|
return prev[lb]
|
|
}
|
|
BEGIN { best = 99 }
|
|
{ d = dist(w, $0); if (d < best) { best = d; hit = $0 } }
|
|
END { if (best <= 2) print hit }'
|
|
}
|
|
|
|
unknown_command() {
|
|
local hint; hint="$(suggest "$1")"
|
|
if [ -n "$hint" ]; then
|
|
echo "box: unknown command: $1 — did you mean '$hint'?" >&2
|
|
else
|
|
echo "box: unknown command: $1" >&2
|
|
fi
|
|
echo "try 'box help' for the command list." >&2
|
|
exit 2
|
|
}
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
|
|
|
|
USAGE
|
|
box <command> [<args>] [options]
|
|
|
|
COMMANDS
|
|
EOF
|
|
local r v sum
|
|
for r in "${CMDS[@]}"; do
|
|
IFS='^' read -r v _ _ sum _ _ _ <<<"$r"
|
|
printf ' %-13s %s\n' "$v" "$sum"
|
|
done
|
|
cat <<'EOF'
|
|
|
|
OPTIONS
|
|
--name <box> Name for the new box (new, import)
|
|
--template <t> Template to mint from (default: blank) (new)
|
|
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
|
|
--vm Force VM mode: the trust-less target (new)
|
|
--container Force container mode: weaker isolation, (new)
|
|
dev/test only. Default where /dev/kvm is absent.
|
|
--json Emit Incus JSON instead of a table (list, info)
|
|
--instance-only Export the live state only, no snapshots (export)
|
|
--force, -f Destroy without asking (rm, restore); overwrite the
|
|
file (export)
|
|
--remote <r> Act on Incus remote <r> (any)
|
|
--help, -h Help; after a command, help for that command
|
|
--version, -V Print the box version
|
|
|
|
Options come after the command: 'box list --json', not 'box --json list'.
|
|
|
|
EXAMPLES
|
|
# mint a claude box and log in inside it — the tool never handles your token
|
|
box new --name work --template claude-box
|
|
box shell work # then: run 'claude', then /login
|
|
|
|
# log in once, reuse forever: checkpoint the authed box, clone from it
|
|
box snapshot work authed
|
|
box new --name feature --from work/authed
|
|
|
|
# the default: a blank box — same isolation, nobody home
|
|
box new --name scratch
|
|
box templates
|
|
|
|
# what have I got, and what can I clone?
|
|
box list
|
|
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
|
|
box exec work -- git -C project pull
|
|
|
|
# anything box doesn't wrap: boxes are plain Incus instances
|
|
box incus work -- config show
|
|
|
|
EXIT STATUS
|
|
0 ok
|
|
1 it went wrong (Incus failed, no such box, aborted at a prompt)
|
|
2 you asked wrong (unknown command, bad flag, destructive act without --force)
|
|
|
|
THE MODEL
|
|
A box carries NO credentials. You authenticate interactively inside it
|
|
('claude' then /login; 'gh auth login'); box never stores or injects a
|
|
secret. A box reaches the public internet and nothing else — there is no
|
|
inbound path. Destroying a box loses nothing you didn't push.
|
|
|
|
box owns a command when it must enforce something Incus cannot see: the
|
|
user.box=1 boundary, the isolation stack, or the creds-free snapshot
|
|
workflow. Everything else is Incus's job — and 'box incus' is the door.
|
|
|
|
On a shared host, an admin hands out the restricted tier per user
|
|
('box grant <user>'): their own boxes, the same hardened network, and
|
|
no view of anyone else's. 'box help grant' has the contract.
|
|
|
|
Docs: https://github.com/heavy-duty/box
|
|
EOF
|
|
}
|
|
|
|
# The synopsis and the summary come from the table; only prose lives here, and
|
|
# only where a command has something to say beyond its summary.
|
|
help_cmd() {
|
|
echo "usage: $(synopsis_of "$1")"
|
|
echo
|
|
case "$1" in
|
|
new) cat <<'EOF'
|
|
Mint a box. Without --from, launches a fresh box from a template (default:
|
|
blank — bare Debian 13, nobody home; --template claude-box gets Claude Code
|
|
installed, creds-free, ~10 min cold). With --from, clones an existing box or
|
|
one of its snapshots — login state, git creds and clones carry over,
|
|
isolation is preserved, and the clone knows its template's user without
|
|
being told.
|
|
|
|
A tenant template (claude-box, codex-box, grok-box, staging-box) is a THIN seed — the user,
|
|
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
|
|
role inside it ('rig bootstrap <role>', rig#31): that role installs the
|
|
agent CLI / server posture and the agent-context file. rig is preinstalled
|
|
from RIG_REPO/RIG_REF in the mint environment (default heavy-duty/rig@main,
|
|
unpinned — an honest edge until rig has releases). Anything that joins a
|
|
tailnet or holds a key stays operator-run, never auto-run.
|
|
|
|
--name <box> Required. The box's name.
|
|
--template <t> Template to mint from; 'box templates' lists them.
|
|
A template sets image, user, resources, boot
|
|
demands (independently: BOX_REQUIRE_VM insists on
|
|
VM mode, BOX_AUTOSTART survives host reboots) and
|
|
a creds-free tenant role (BOX_BOOTSTRAP_ROLE) —
|
|
never the network: every template gets the same
|
|
isolation.
|
|
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
|
|
--cpu <n> CPUs for this mint (limits.cpu, verbatim to Incus).
|
|
--memory <size> RAM for this mint, e.g. 3GiB (limits.memory).
|
|
--disk <size> Root disk size, e.g. 20GiB. VM mode only — a
|
|
container's root rides the storage pool.
|
|
--vm | --container Force the mode. VM is the trust boundary and the
|
|
default wherever /dev/kvm exists; container mode
|
|
(security.nesting=true) is the fallback for hosts
|
|
without nested virt — weaker isolation, dev/test only.
|
|
A template that requires VM mode refuses both the
|
|
fallback and --container.
|
|
|
|
Resources resolve most-specific-first: these flags, then BOX_CPU /
|
|
BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
|
|
template's box.env, then defaults. Flags shape a fresh mint only — a --from
|
|
clone carries its source's resources. Resources are all a flag can touch:
|
|
there is no flag for a network or a security key, on purpose.
|
|
|
|
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
|
|
a launch that overruns it fails loudly instead of hanging forever (#93).
|
|
|
|
Every fresh mint marks a snapshot called 'pristine' (#104) at the one moment
|
|
it is true: after cloud-init, before rig converges anything. That is the
|
|
guest as pristine Debian plus box's thin seed, and it is what
|
|
|
|
box restore <box> pristine
|
|
|
|
rolls back to — undoing the tenant role and everything done since, without
|
|
paying a re-mint. It is an UNDO, not a backup: 'box rm' deletes a box and
|
|
every snapshot it has, so 'box export' remains the only state that outlives
|
|
the box. A --from clone takes no 'pristine' of its own — it has no pristine
|
|
moment; it inherits the source's snapshots (copying a box) or starts with
|
|
none (copying a snapshot). On a 'dir'-backend host, where a snapshot is a
|
|
full copy rather than a CoW mark, the mint skips it and says so.
|
|
BOX_SNAPSHOT_PRISTINE=0 skips it anywhere.
|
|
|
|
A mint whose template names a bootstrap role marks a second snapshot,
|
|
'bootstrapped' (#130), once the rig hook has run and box has watched it
|
|
SUCCEED — the box converged and not yet touched. It is the cheaper undo:
|
|
'pristine' throws the tenant role away, 'bootstrapped' keeps it and throws
|
|
away only what you did afterwards. Same rules: never fatal, skipped on a
|
|
'dir' pool, and BOX_SNAPSHOT_BOOTSTRAPPED=0 skips it anywhere.
|
|
|
|
It is deliberately NOT unconditional the way 'pristine' is. A blank box
|
|
runs no hook, so there is no convergence to mark and it gets none — the
|
|
label marks an event, not a mint. And if the hook FAILS, box tells you to
|
|
re-run it by hand and takes no mark afterwards: a by-hand run happens in a
|
|
shell box does not watch, so box will not label a convergence it never saw.
|
|
It names 'box snapshot <box> bootstrapped' at that moment instead. So read
|
|
the label one way only: its PRESENCE means the hook converged untouched;
|
|
its ABSENCE means nothing at all.
|
|
|
|
box new --name scratch # blank, the default
|
|
box new --name work --template claude-box
|
|
box new --name lean --template claude-box --cpu 2 --memory 3GiB
|
|
box new --name feature --from work/authed
|
|
EOF
|
|
;;
|
|
templates) cat <<'EOF'
|
|
List the templates this install can mint, with their descriptions. A template
|
|
is a directory under templates/: a box.env (image, user, resources, boot
|
|
demands, tenant role — parsed against an allowlist, never sourced) and a
|
|
user-data.yaml (cloud-init, passed to Incus verbatim except the rig pin
|
|
tokens @RIG_REPO@/@RIG_REF@, resolved at mint from the environment).
|
|
Templates cannot touch the network or security flags — the shared box-net
|
|
profile is the placement contract, so every template gets the same isolation.
|
|
|
|
Templates are thin, creds-free seeds (#81): the user, tmux and rig — what a
|
|
box BECOMES lives in rig's bootstrap roles (rig#31), auto-run at mint via
|
|
BOX_BOOTSTRAP_ROLE. box mints; rig converges.
|
|
|
|
box templates
|
|
box new --name scratch --template blank
|
|
EOF
|
|
;;
|
|
list) cat <<'EOF'
|
|
List the boxes box minted on this host: name, state, type, snapshot count.
|
|
Takes no box — for one box, that's 'box info <box>'.
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
box list
|
|
EOF
|
|
;;
|
|
info) cat <<'EOF'
|
|
Show one box: state, type, IP address, and — the reason this exists — the
|
|
labels of its snapshots, with the --from line to clone one.
|
|
|
|
--json Incus's JSON, straight through, for scripting.
|
|
|
|
box info work
|
|
EOF
|
|
;;
|
|
shell) cat <<'EOF'
|
|
Open an interactive shell in a running box as the 'claude' user. This is the
|
|
only entry path — there is no SSH and no inbound route to a box.
|
|
|
|
box shell work
|
|
EOF
|
|
;;
|
|
exec) cat <<'EOF'
|
|
Run a command inside a box as the 'claude' user. Everything after -- is passed
|
|
through untouched; the -- is required, or box will read your command's
|
|
flags as its own.
|
|
|
|
box exec work -- git -C project pull
|
|
box exec work -- claude --version
|
|
EOF
|
|
;;
|
|
tmux) cat <<'EOF'
|
|
A shell that survives you. 'shell' is a child of the exec connection — if your
|
|
terminal or SSH session drops, everything running in it is SIGHUP'd, and a
|
|
long Claude run dies with it. This attaches a tmux session instead
|
|
(new-session -A): created if new, reattached if it exists — so starting work
|
|
and resuming after a disconnect are the same command, with no state to
|
|
remember.
|
|
|
|
The session name (default: main) buys parallel streams in one box:
|
|
|
|
box tmux work # attach or create 'main'
|
|
box tmux work run-1 # a second, independent stream, same box
|
|
|
|
Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of
|
|
tmux's semantics, 'box shell' is unchanged.
|
|
EOF
|
|
;;
|
|
snapshot) cat <<'EOF'
|
|
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
|
|
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
|
|
label defaults to manual-<epoch>; 'box info <box>' shows the labels you
|
|
have.
|
|
|
|
box snapshot work authed
|
|
|
|
Two labels are taken for you (see 'box help restore'): every fresh mint
|
|
marks 'pristine' after cloud-init and before rig's tenant role runs (#104),
|
|
and a mint with a bootstrap role marks 'bootstrapped' after box has watched
|
|
that role converge (#130). 'bootstrapped' is skipped where there was no hook
|
|
to watch — a blank box, or a hook box told you to re-run by hand — so its
|
|
absence proves nothing. Take it yourself in that case:
|
|
|
|
box snapshot work bootstrapped
|
|
|
|
Snapshots do not outlive their box: 'box rm' deletes a box and every
|
|
snapshot it has. They are an undo, not a backup — 'box export' is the
|
|
durable path.
|
|
EOF
|
|
;;
|
|
restore) cat <<'EOF'
|
|
Roll a box back to one of its snapshots, in place. Anything in the box since
|
|
that snapshot is lost. 'box info <box>' lists the labels.
|
|
|
|
box restore work authed
|
|
box restore work authed --force
|
|
box restore work pristine
|
|
|
|
'pristine' is the one label box takes for you (#104): every fresh mint marks
|
|
it after cloud-init and before rig converges the tenant role, so it is the
|
|
guest as pristine Debian plus box's thin seed. Restoring it is the answer to
|
|
"I broke this box" for every tenant role — everything 'rig bootstrap
|
|
<tenant>' does is box-local and file-shaped, so a filesystem rollback undoes
|
|
all of it, without a ~10-minute re-mint.
|
|
|
|
What it does NOT undo is anything that left the box: a tailnet join, a
|
|
GitHub runner registration, a pushed commit. Those are off-box records a
|
|
filesystem rollback cannot reach (heavy-duty/rig#62). And 'pristine' is not
|
|
a backup — it dies with the box on 'box rm'; 'box export' is what survives.
|
|
|
|
A box minted with --from has no 'pristine' of its own unless it inherited
|
|
one from its source, because a clone has no pristine moment to capture.
|
|
|
|
'bootstrapped' is the other label box takes for you (#130), and it is the
|
|
one you probably want more often: it is the box AFTER the tenant role
|
|
converged and before you touched it.
|
|
|
|
box restore work bootstrapped # keep the role, undo what you did to it
|
|
box restore work pristine # throw the role away too
|
|
|
|
The same two caveats apply to it exactly as written above — it dies with the
|
|
box on 'box rm' ('box export' is what survives), and it cannot undo anything
|
|
that left the box, a tailnet join or a runner registration among them
|
|
(heavy-duty/rig#62).
|
|
|
|
Its absence proves NOTHING. box takes it only after a rig hook it ran and
|
|
watched succeed, so a blank box has none, a 'dir'-pool host has none, and a
|
|
box whose hook failed and was re-run by hand through 'box shell' has none
|
|
either — box will not label a convergence it did not watch. 'box info <box>'
|
|
tells you which labels a box actually has; take the mark yourself any time
|
|
with 'box snapshot <box> bootstrapped'.
|
|
|
|
Destructive, so it asks first — naming the snapshot it is rolling back to,
|
|
because the whole risk is picking the wrong label. --force (-f) skips the
|
|
prompt; with no TTY to ask on it refuses rather than assuming yes.
|
|
|
|
box does not require the box to be stopped for this. Snapshots here are
|
|
stateless — no live memory is captured — so a rollback is crash-consistent:
|
|
the box comes back the way a machine comes back from losing power. 'box down
|
|
<box>' first if that matters.
|
|
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
|
|
· the import EVENT — when the box landed here, and by which box version
|
|
|
|
The mint stamp is NOT rewritten. The artifact's mint time, box version, image,
|
|
template and origin (mint or clone) are the originating host's truth and
|
|
survive the trip intact — 'box info' shows them unchanged, with an IMPORTED
|
|
line under them saying the mint predates the arrival. In particular the import
|
|
does not overwrite 'origin': a clone that was exported and imported still
|
|
reads as a clone, and still names the box it was cloned from.
|
|
|
|
A box that makes the trip more than once keeps both ends: the first import is
|
|
pinned forever, the latest is refreshed each time, and a count says how many.
|
|
|
|
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 a box. Incus cannot rename a running instance, so stop it first:
|
|
|
|
box down work
|
|
box rename work archive
|
|
box start archive
|
|
|
|
Snapshots and Claude auth follow the box; anything referring to the old name by
|
|
hand (a --from line, a script) does not.
|
|
EOF
|
|
;;
|
|
rm) cat <<'EOF'
|
|
Delete a box and every snapshot it has. This cannot be undone, so it asks for
|
|
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
|
|
(a script, a pipe), it refuses unless --force is given.
|
|
|
|
box rm work
|
|
box rm work --force
|
|
EOF
|
|
;;
|
|
expose) cat <<'EOF'
|
|
Open a deliberate, loopback-only door to a port inside a box — for when you
|
|
are coding in a box and want to see its dev server in your browser.
|
|
|
|
box expose <box> <port> [<host-port>] # forward 127.0.0.1:<host-port> → box:<port>
|
|
box expose <box> --list # what doors are open
|
|
box expose <box> --remove <port> # close one
|
|
|
|
The host side ALWAYS listens on 127.0.0.1 — no other machine can reach the
|
|
box, only this host's loopback. There is no flag to widen that; if you need
|
|
LAN exposure you are leaving the tool's threat model, and 'box incus' is the
|
|
door (with its warning).
|
|
|
|
The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
|
|
a VM's forwarder connects to the box over the network. Inside an isolated box
|
|
that is safe: nothing but this proxy can reach the port.
|
|
|
|
box new --name web --template claude-box
|
|
box shell web # inside: run a dev server on 0.0.0.0:3000
|
|
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
|
|
EOF
|
|
;;
|
|
incus) cat <<'EOF'
|
|
The door out. box wraps the box lifecycle and the isolation model, not
|
|
all of Incus — so when you need something it doesn't wrap, run Incus through
|
|
here and keep the safety rail that matters: the box name is resolved and
|
|
checked against the user.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
|
|
box didn't mint.
|
|
|
|
Everything after -- is passed to incus verbatim. A literal {} is replaced with
|
|
the resolved instance name; with no {}, the instance is appended at the end.
|
|
The command that will run is echoed before it runs.
|
|
|
|
box incus work -- config show
|
|
box incus work -- config device add {} extra disk source=/data path=/data
|
|
|
|
Changing the profile, the network, a device or a security.* key can take a box
|
|
outside the isolation stack. box warns and then does as you asked — from
|
|
there, the trust boundary is yours to keep.
|
|
EOF
|
|
;;
|
|
doctor) cat <<'EOF'
|
|
Answer "is this host fit to mint boxes?" from ground truth, not config claims:
|
|
is the Incus daemon answering, is a dnsmasq actually serving boxnet, does
|
|
the kernel's bridge port say 'isolated on', is the resolver pinned or is a
|
|
host VPN's DNS leaking into boxes, can a box actually resolve names — and
|
|
the #80 nested-stack signature: a default gateway held as a LOCAL address,
|
|
or duplicate connected routes for the uplink subnet, judged on this machine
|
|
AND inside every box it probes (a box stack installed inside a box squats on
|
|
the guest's gateway and blackholes its egress, intermittently). Every check
|
|
exists because its fault has happened — most kill a cold mint with a
|
|
cloud-init error that names none of them.
|
|
|
|
--fix also revert what a drill run may have left behind
|
|
--pin-dns pin boxnet's resolver to public upstreams and re-test
|
|
(setup-host.sh now pins by default; this is the quick test)
|
|
|
|
box doctor
|
|
box doctor --fix
|
|
|
|
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
|
|
unless --fix or --pin-dns is given.
|
|
EOF
|
|
;;
|
|
setup-host) cat <<'EOF'
|
|
Prepare this host to mint boxes — one time. Installs Incus and builds the
|
|
isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate
|
|
ACL, the box-net profile, and the firewall rules, all re-applied at boot.
|
|
Idempotent — safe to re-run after a box upgrade to pick up stack changes;
|
|
install.sh runs it for you, so this is for re-applying by hand.
|
|
|
|
One run is enough. If it has to add you to the incus-admin group it re-runs
|
|
itself under that group — no re-login, no second invocation.
|
|
|
|
The stack's subnet: 10.88.0.0/24 when free; with an existing boxnet it
|
|
converges on the bridge's own subnet; and when the default is claimed by
|
|
something else — most tellingly this machine's own default gateway, i.e.
|
|
setup-host running INSIDE a box — it auto-picks the first free /24 from
|
|
10.89.0.0/24 through 10.127.0.0/24 and says so. A nested stack on the
|
|
guest's own uplink subnet would capture its gateway address and blackhole
|
|
its egress, intermittently (issue #80); the auto-pick is why a drill or
|
|
rehearsal inside a box now works with zero flags. BOX_SUBNET pins the
|
|
subnet explicitly (the bridge, the gateway carve-out and the firewall all
|
|
derive from it) — a pin is never overridden: setup-host REFUSES, before
|
|
touching anything, when the pinned subnet is claimed by a foreigner or
|
|
disagrees with an existing bridge.
|
|
|
|
box setup-host # picks/converges by itself
|
|
BOX_SUBNET=10.90.0.0/24 box setup-host # scripted hosts: pin it
|
|
|
|
Multi-user hosts: setup-host builds the stack once, for everyone. An admin
|
|
then hands individual users the restricted tier with 'box grant <user>' —
|
|
their own boxes, on this same hardened network, seeing nobody else's.
|
|
EOF
|
|
;;
|
|
grant) cat <<'EOF'
|
|
Give a host user the restricted tier. They get their own Incus project (via
|
|
incus-user), and every box they mint lands on the SAME hardened boxnet as an
|
|
admin's — the full isolation contract (ACL, DNS isolation, resolver pin,
|
|
port isolation, the box-to-box drop), with no view of anyone else's boxes.
|
|
|
|
What it converges, idempotently (safe to re-run, and re-run after upgrades):
|
|
· puts the user in the 'incus' group (not incus-admin — that is the point)
|
|
· creates their user-<uid> project by touching incus-user for them
|
|
· 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
|
|
unreferenced and unreachable, not just unused
|
|
· 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
|
|
|
|
An incus-admin member is provisioned too, not refused (#99): they are added
|
|
to 'incus' like anyone else — not a new privilege, since incus-admin already
|
|
opens the daemon, but the key to a FILE, because incus-user's socket is group
|
|
'incus' mode 0660 and nothing below can provision them without it. Everything
|
|
else converges, so they finally have a project of their own. What it is not
|
|
is a confinement:
|
|
incus-admin wins at the socket, so the restrictions are a default placement
|
|
they can step outside at will, and their own client keeps resolving to the
|
|
admin socket (and the default project) until incus-admin is taken away. The
|
|
grant says all of that out loud when it lands.
|
|
|
|
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
|
|
import/rm on their own boxes. Not theirs: expose (edits daemon-global
|
|
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.
|
|
|
|
box grant dev1
|
|
EOF
|
|
;;
|
|
revoke) cat <<'EOF'
|
|
Take the restricted tier back from a user. Without --purge, this removes
|
|
them from the 'incus' group: their project and boxes stay (still running!)
|
|
and 'box grant' restores access untouched. Group membership is read at
|
|
LOGIN, so a session they already hold keeps the socket until it ends —
|
|
revoke says so and names the loginctl command when it happens. With
|
|
--purge, their sessions are terminated first (a stale session could quietly
|
|
recreate the project, unhardened, afterwards — measured, not theoretical),
|
|
then their boxes, images, project, private bridge and trust-store
|
|
certificate are removed — irreversible, so it asks first.
|
|
|
|
On an incus-admin member a bare revoke takes back the 'incus' membership that
|
|
grant added — reported as 'partial:', because it ends no access: incus-admin
|
|
still opens every project on this host. (One who was never granted is a named
|
|
no-op instead.) Mind what that leaves behind: with 'incus' gone, a later
|
|
'gpasswd -d <user> incus-admin' drops them into NEITHER group and their ready
|
|
project becomes unreachable — grant's "no re-grant needed" holds only while
|
|
they still hold 'incus'. --purge unmakes the provisioning the same way. Only
|
|
'gpasswd -d <user> incus-admin' ends their access, and revoke says so.
|
|
|
|
box revoke dev1 # take the tier; their boxes keep running
|
|
box revoke dev1 --purge # ...or end their sessions and delete everything
|
|
EOF
|
|
;;
|
|
teardown-host) cat <<'EOF'
|
|
Remove the box host stack — all boxes, the boxnet/claudenet networks, the
|
|
ACLs, the profiles, and the firewall rules of BOTH name generations (current
|
|
and pre-0.4.0). Asks first.
|
|
|
|
box teardown-host # the stack; leaves Incus installed
|
|
box teardown-host --purge-incus # ...and uninstall Incus too
|
|
EOF
|
|
;;
|
|
migrate-host) cat <<'EOF'
|
|
Move a host from the pre-0.4.0 'claudebox' stack onto 'box'. Re-homing
|
|
preserves a box's authed state (no re-login) — it only re-tags and reassigns
|
|
the profile, then verifies the box works on its new network leg.
|
|
|
|
box migrate-host --box <name> # re-home one legacy box
|
|
box migrate-host --all-boxes # re-home every legacy box
|
|
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
|
|
EOF
|
|
;;
|
|
versions) cat <<'EOF'
|
|
List the versions installed under this install root — install.sh lands each
|
|
one side by side at <root>/versions/<v>, and a 'current' symlink tracks the
|
|
default (what the box on your PATH runs). The default is marked (current);
|
|
the tree answering THIS command is marked (running) — they differ when your
|
|
PATH resolves a different install (say, a global /opt/box shadowing yours).
|
|
|
|
box versions
|
|
box use <version> # switch the default
|
|
# install another version side by side: re-run install.sh
|
|
EOF
|
|
;;
|
|
use) cat <<'EOF'
|
|
Switch the default box version — repoint the 'current' symlink (and the PATH
|
|
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,
|
|
'box export' what you keep (one
|
|
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
|
|
'box --version' must answer it.
|
|
|
|
box versions # what is installed
|
|
box use 0.6.0
|
|
EOF
|
|
;;
|
|
uninstall) cat <<'EOF'
|
|
Remove one installed version, or the whole install — the real uninstall,
|
|
replacing the old "rm -rf two paths by hand" instructions.
|
|
|
|
box uninstall <version> one NON-current version ('box use' another
|
|
first if you are on it)
|
|
box uninstall everything: every version, the current and
|
|
box uninstall --all PATH symlinks, and any legacy claudebox crumbs
|
|
box uninstall --purge-host run teardown-host first (all boxes, the boxnet
|
|
stack, the firewall — its own confirmation),
|
|
then remove the install
|
|
|
|
The full uninstall runs in the safe order: boxes first — it refuses while
|
|
any exist (and names them) unless --purge-host tears them down; then the
|
|
trees and symlinks; and it ENDS with an absence assert — every removed path
|
|
is re-checked, and anything still present makes it exit 1 naming the
|
|
leftovers instead of reporting a clean uninstall that wasn't (the same
|
|
discipline as 'box revoke --purge'). Asks before removing; --force or
|
|
BOX_YES=1 skips the prompt. On a multi-user host, revoke granted users
|
|
first: 'box revoke <user> --purge'.
|
|
|
|
box uninstall 0.5.0
|
|
box uninstall --all --purge-host
|
|
EOF
|
|
;;
|
|
status) cat <<'EOF'
|
|
Deprecated alias for 'box list'. It ignored the <box> argument it
|
|
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
|
|
still works, and forwards to 'list'.
|
|
EOF
|
|
;;
|
|
help) cat <<'EOF'
|
|
Print the general help, or the help for one command.
|
|
|
|
box help
|
|
box help rename
|
|
EOF
|
|
;;
|
|
*) field "$1" sum ;; # no prose: the table's summary is the help
|
|
esac
|
|
}
|
|
|
|
show_help() { # "" → general help
|
|
case "${1:-}" in
|
|
""|help) usage ;;
|
|
*) is_command "$1" || unknown_command "$1"; help_cmd "$1" ;;
|
|
esac
|
|
}
|
|
|
|
cmd="${1:-help}"; shift || true
|
|
case "$cmd" in
|
|
-h|--help) usage; exit 0 ;;
|
|
-V|--version) version; exit 0 ;;
|
|
-*) usage_error "options come after the command — try 'box <command> $cmd ...'" ;;
|
|
esac
|
|
|
|
args=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
|
|
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
|
|
--template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;;
|
|
--cpu) [ $# -ge 2 ] || usage_error "--cpu needs a value"; cpu="$2"; shift 2 ;;
|
|
--memory) [ $# -ge 2 ] || usage_error "--memory needs a value"; memory="$2"; shift 2 ;;
|
|
--disk) [ $# -ge 2 ] || usage_error "--disk needs a value"; disk="$2"; shift 2 ;;
|
|
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
|
|
--vm) mode=vm; shift ;;
|
|
--container) mode=container; shift ;;
|
|
--instance-only) instance_only=1; shift ;;
|
|
--force|-f) force=1; shift ;;
|
|
--json) json=1; shift ;;
|
|
--help|-h) want_help=1; shift ;;
|
|
--version|-V) version; exit 0 ;;
|
|
--) shift; args+=("$@"); break ;;
|
|
# An unrecognized flag used to be swallowed as a positional — so a typo'd
|
|
# --labl silently became a snapshot's label. Say so instead.
|
|
-*)
|
|
# doctor's flags belong to the doctor script, not to box
|
|
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
|
|
# expose's own flags (--list, --remove) are positional to it, not box's
|
|
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
|
|
# the host verbs delegate their flags to the scripts they wrap;
|
|
# uninstall parses its own (--all, --purge-host) the same way
|
|
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) args+=("$1"); shift; continue ;; esac
|
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
|
fi
|
|
usage_error "unknown option: $1 (see 'box help $cmd')" ;;
|
|
*) args+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
|
|
|
|
# --- preconditions ---------------------------------------------------------
|
|
|
|
iname_of() { echo "$remote$1"; } # instance name = box name
|
|
|
|
# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
|
|
# somebody's VM, and box will not stop, rename or delete it by accident.
|
|
resolve_box() {
|
|
local box="$1" i tag
|
|
i="$(iname_of "$box")"
|
|
tag="$(incus config get "$i" user.box 2>/dev/null || true)"
|
|
# A pre-rename box carries user.claudebox=1 and nothing else. Snapshots of
|
|
# old boxes outlive the release that minted them — the legacy tag is honored
|
|
# forever, or an old box stops being a box at all.
|
|
[ "$tag" = "1" ] || tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
|
|
[ "$tag" = "1" ] || die "no such box: $box (see 'box list')"
|
|
echo "$i"
|
|
}
|
|
|
|
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() {
|
|
local i="$1" box="$2" why="${3:-Incus needs it stopped for this}" st; st="$(box_state "$i")"
|
|
case "$st" in
|
|
STOPPED|Stopped|stopped) return 0 ;;
|
|
*) die "box '$box' is ${st:-not stopped} — $why. Stop it: box down $box" ;;
|
|
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() {
|
|
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
fi
|
|
}
|
|
|
|
need_arg2() {
|
|
if [ "${#args[@]}" -lt 2 ] || [ -z "${args[1]}" ]; then
|
|
usage_error "usage: $(synopsis_of "$cmd")"
|
|
fi
|
|
}
|
|
|
|
# Row templates: {} -> $2, {1} -> the second positional. The caller picks what
|
|
# {} means because the two uses differ on purpose — the ok message reports on
|
|
# the box the operator NAMED, the confirm prompt names the RESOLVED instance,
|
|
# because a prompt about to destroy something should say which machine.
|
|
fill() { # $1 = template, $2 = what {} stands for
|
|
local t="${1//\{\}/$2}"
|
|
printf '%s\n' "${t//\{1\}/${args[1]:-}}"
|
|
}
|
|
|
|
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
|
|
if [ "$force" -eq 1 ]; then return 0; fi
|
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
|
|
local reply
|
|
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
|
|
# EOF (Ctrl-D) is an answer, and it means no. Unguarded, 'read' returns
|
|
# non-zero and 'set -e' ends the run in silence — heavy-duty/rig#43.
|
|
read -r reply || die "aborted."
|
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
|
}
|
|
|
|
# --- commands with real work -----------------------------------------------
|
|
|
|
pick_mode() {
|
|
if [ "$mode" != auto ]; then echo "$mode"; return; fi
|
|
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
|
|
echo "box: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
|
|
echo container
|
|
fi
|
|
}
|
|
|
|
# Five minutes, not three: the first VM launch on a fresh pool unpacks the
|
|
# image into a pool volume and takes the coldest possible boot — measured
|
|
# live, an agent can need past the 3-minute mark exactly once per pool while
|
|
# every later boot answers in seconds. And when it still fails, ship the
|
|
# forensics: the VM's console says why, and the box is torn down by whoever
|
|
# called us before anyone can read it.
|
|
wait_agent() {
|
|
local n="$1" i clog
|
|
echo "box: waiting for instance agent..."
|
|
for i in $(seq 1 150); do
|
|
if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
|
|
if [ "$i" -eq 150 ]; then
|
|
# The console log is FULL of terminal escape sequences (boot messages,
|
|
# a firmware menu). Dumping it raw scrambles the operator's terminal —
|
|
# and doubly so when it lands in a log someone is tail -f'ing. Capture
|
|
# it to a file, STRIP everything but printable ASCII + tab/newline, and
|
|
# print only a short sanitized tail. Nothing raw ever reaches a terminal.
|
|
clog="/tmp/box-console-$n.log"
|
|
timeout -k 5 15 incus console "$n" --show-log </dev/null >"$clog.raw" 2>/dev/null || true
|
|
# Strip whole escape sequences FIRST (while the ESC byte is present), then
|
|
# drop any residual control bytes — otherwise 'tr' alone leaves the visible
|
|
# '[1m[37m' halves behind. Result is clean, readable text.
|
|
sed -E $'s/\x1b\\[[0-9;:?]*[ -/]*[@-~]//g; s/\x1b[()#][0-9A-Za-z]//g; s/\x1b[=>PX^_].*?(\x1b\\\\|\x07)//g; s/\x1b.//g' \
|
|
"$clog.raw" 2>/dev/null | tr -cd '\11\12\40-\176' >"$clog"
|
|
rm -f "$clog.raw"
|
|
echo "box: instance agent never came up after 5 minutes." >&2
|
|
echo "box: sanitized console log → $clog (last non-blank lines:)" >&2
|
|
grep -v '^[[:space:]]*$' "$clog" 2>/dev/null | tail -6 | sed 's/^/ /' >&2
|
|
# A box that never boots is NOT a slow box, and the console says which
|
|
# failure it is. Each of these cost hours to diagnose by hand once; the
|
|
# box that hits them next should be told the answer, not the symptom.
|
|
if grep -qiE 'Failed to decompress kernel|efi_stub_entry\(\) failed' "$clog" 2>/dev/null; then
|
|
echo "box: THE KERNEL WOULD NOT DECOMPRESS — the cached image is corrupt." >&2
|
|
echo "box: (a truncated/bad image download does exactly this). Re-pull it:" >&2
|
|
echo "box: incus image list # find the fingerprint" >&2
|
|
echo "box: incus image delete <fingerprint> # the next mint re-downloads" >&2
|
|
elif grep -qiE 'bad shim signature|prohibited by secure boot' "$clog" 2>/dev/null; then
|
|
echo "box: SECURE BOOT rejected the kernel — but box mints VMs with" >&2
|
|
echo "box: security.secureboot=false, so this box predates that fix or was" >&2
|
|
echo "box: created by hand. Re-mint it with a current box." >&2
|
|
elif grep -qiE 'GNU GRUB|Press enter to boot|UEFI Firmware Settings' "$clog" 2>/dev/null; then
|
|
echo "box: the VM is stuck at the GRUB/firmware menu — it never booted." >&2
|
|
echo "box: this is the IMAGE, not box. Re-pull it (incus image delete …)," >&2
|
|
echo "box: or pin a known-good build in the template's BOX_IMAGE." >&2
|
|
fi
|
|
die "agent unreachable (inspect live: incus console $n)"
|
|
fi
|
|
sleep 2
|
|
done
|
|
}
|
|
|
|
# A clone must not BE its source. Incus regenerates the MAC, but /etc/machine-id
|
|
# rides along inside the disk — and systemd derives its DHCP client identifier
|
|
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
|
|
# to the second on the lease timer. Every box cloned from one snapshot collided
|
|
# on the network, which is exactly the workflow box exists for (log in
|
|
# once, snapshot, clone forever).
|
|
#
|
|
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
|
|
# the reset costs one reboot. Do it before handing the box over, never after.
|
|
reset_identity() {
|
|
local i="$1"
|
|
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
|
|
# 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
|
|
# fix. 'systemd-machine-id-setup' writes a fresh VALID id instead; in a VM it
|
|
# derives from the DMI product UUID, which Incus makes unique per instance.
|
|
incus exec "$i" -- sh -c '
|
|
rm -f /etc/machine-id /var/lib/dbus/machine-id
|
|
systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id
|
|
ln -sf /etc/machine-id /var/lib/dbus/machine-id
|
|
test -s /etc/machine-id
|
|
' </dev/null || die "could not reset the clone's machine-id"
|
|
# The new id only takes effect at boot. Ask nicely, then insist — a clone that
|
|
# keeps its source's DHCP lease is worse than an unclean stop of a box that
|
|
# booted 30 seconds ago.
|
|
incus restart --timeout 60 "$i" >/dev/null 2>&1 || incus restart -f "$i"
|
|
wait_agent "$i"
|
|
}
|
|
|
|
# Templates set image, user, resources, boot demands, a tenant role and
|
|
# cloud-init — NOTHING else. The box.env file is parsed against this allowlist,
|
|
# never sourced: sourcing would hand every template arbitrary bash execution on
|
|
# the HOST at mint time. And there is deliberately no key for a network or a
|
|
# security flag — the shared box-net profile is the placement contract, so no
|
|
# template can weaken isolation. 'blank' is a box with nobody home, not a box
|
|
# with the safety off. The two boot demands are for server-class templates
|
|
# (#68): BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust
|
|
# boundary, and a server-class guest runs docker), and BOX_AUTOSTART=1 stamps
|
|
# boot.autostart so the box survives a host reboot without an operator.
|
|
# BOX_BOOTSTRAP_ROLE (#81) names the rig role box auto-runs after mint — the
|
|
# thin-template split: the seed is user + tmux + rig, and what the box BECOMES
|
|
# is 'rig bootstrap <role>'. Only creds-free roles belong here, by contract.
|
|
load_template() {
|
|
local t="$1" dir line key val
|
|
dir="$root/templates/$t"
|
|
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
|
|
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
|
|
T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
|
|
T_REQUIRE_VM=""; T_AUTOSTART=""; T_BOOTSTRAP_ROLE=""
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
case "$line" in ''|\#*) continue ;; esac
|
|
case "$line" in
|
|
*=*) key="${line%%=*}"; val="${line#*=}" ;;
|
|
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
|
|
esac
|
|
val="${val#\"}"; val="${val%\"}"
|
|
# T_DESC is parsed for symmetry with the other BOX_* keys, but cmd_templates
|
|
# re-reads BOX_DESCRIPTION straight from the file (a box is listed without
|
|
# ever loading its template), so the parsed value here is never read. Keep the
|
|
# row — deleting it would turn box.env's own key into an "unknown key" error at
|
|
# mint time. (SC2034 disabled for the branch below; the directive must sit on
|
|
# the whole case, not an individual arm.)
|
|
# shellcheck disable=SC2034
|
|
case "$key" in
|
|
BOX_DESCRIPTION) T_DESC="$val" ;;
|
|
BOX_IMAGE) T_IMAGE="$val" ;;
|
|
BOX_USER) T_USER="$val" ;;
|
|
BOX_CPU) T_CPU="$val" ;;
|
|
BOX_MEMORY) T_MEMORY="$val" ;;
|
|
BOX_DISK) T_DISK="$val" ;;
|
|
BOX_REQUIRE_VM) T_REQUIRE_VM="$val" ;;
|
|
BOX_AUTOSTART) T_AUTOSTART="$val" ;;
|
|
BOX_BOOTSTRAP_ROLE) T_BOOTSTRAP_ROLE="$val" ;;
|
|
*) die "template '$t': unknown key '$key' — a template sets image, user, resources, boot demands and a tenant role, nothing else (there is no key for a network, on purpose)" ;;
|
|
esac
|
|
done <"$dir/box.env"
|
|
# Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
|
|
# dies (which is what we want) — but it reads as an if-then-else it is not, so
|
|
# spell the guard out (SC2015).
|
|
if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then
|
|
die "template '$t': BOX_IMAGE and BOX_USER are required"
|
|
fi
|
|
# A bootstrap role is a rig role NAME and nothing more — it is handed to
|
|
# 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
|
|
# the value must die here, on the host, before a guest ever sees it.
|
|
if [ -n "$T_BOOTSTRAP_ROLE" ] && ! [[ "$T_BOOTSTRAP_ROLE" =~ ^[a-z][a-z0-9-]*$ ]]; then
|
|
die "template '$t': BOX_BOOTSTRAP_ROLE is not a sane role name: $T_BOOTSTRAP_ROLE"
|
|
fi
|
|
# Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
|
|
# > BOX_* environment (how a small host or the drill shrinks every box it
|
|
# mints) > the template's file > defaults. Values pass to Incus verbatim —
|
|
# its units, its validation; box adds no parser of its own. Resources only:
|
|
# there is still no flag for a network or a security.* key, on purpose.
|
|
T_CPU="${cpu:-${BOX_CPU:-${T_CPU:-4}}}"
|
|
T_MEMORY="${memory:-${BOX_MEMORY:-${T_MEMORY:-8GiB}}}"
|
|
T_DISK="${disk:-${BOX_DISK:-${T_DISK:-60GiB}}}"
|
|
}
|
|
|
|
# The ONE substitution a template gets — user-data.yaml is otherwise passed to
|
|
# Incus verbatim. The tenant seeds preinstall rig, which inverts the rig→box
|
|
# install edge (rig#28: rig installs box on hosts; now box guests install rig),
|
|
# and that edge needs a pin point (#81): the seed carries @RIG_REPO@ /
|
|
# @RIG_REF@ tokens, resolved here from the mint environment — RIG_REPO
|
|
# (default heavy-duty/rig) and RIG_REF (default main). Both directions track
|
|
# main unpinned today, said honestly (the same treatment rig#29 gave box's own
|
|
# unpinned install) until a release flow exists (rig#32 / #83). The values are
|
|
# allowlist-validated BEFORE touching the YAML: they land inside a runcmd
|
|
# shell line, so a quote, a space or a newline smuggled through the
|
|
# environment must die on the host, never execute in the guest. bash's =~
|
|
# anchors to the whole string — a multi-line value cannot sneak one clean
|
|
# line past it the way a line-oriented grep would.
|
|
# The rig pin, resolved from the mint environment, in ONE place: render_userdata
|
|
# substitutes it into the seed, and the mint stamp (#103) records it onto the
|
|
# instance. Two spellings of the same default would eventually disagree, and a
|
|
# stamp that disagrees with the seed is worse than no stamp at all.
|
|
rig_repo() { printf '%s\n' "${RIG_REPO:-heavy-duty/rig}"; }
|
|
rig_ref() { printf '%s\n' "${RIG_REF:-main}"; }
|
|
|
|
render_userdata() {
|
|
local f="$1" repo data ref
|
|
repo="$(rig_repo)"; ref="$(rig_ref)"
|
|
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|
|
|| die "RIG_REPO must look like owner/repo: $repo"
|
|
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
|
|
|| die "RIG_REF must be a plain ref name (letters, digits, . _ / -): $ref"
|
|
data="$(cat "$f")"
|
|
data="${data//@RIG_REPO@/$repo}"
|
|
data="${data//@RIG_REF@/$ref}"
|
|
printf '%s\n' "$data"
|
|
}
|
|
|
|
cmd_templates() {
|
|
local d t desc
|
|
echo "TEMPLATES"
|
|
for d in "$root/templates"/*/; do
|
|
t="$(basename "$d")"
|
|
desc="$(grep -m1 '^BOX_DESCRIPTION=' "$d/box.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"
|
|
printf ' %-10s %s\n' "$t" "$desc"
|
|
done
|
|
echo
|
|
echo "mint one: box new --name <box> --template <template>"
|
|
}
|
|
|
|
# When this instance came into being. A timestamp in a CONVERGENT file would be
|
|
# churn — the same run writing a different byte every time — but a mint is not
|
|
# convergent: it happens exactly once, to exactly one instance, and is never
|
|
# re-run against it. UTC and ISO 8601 so it sorts as a string and means the same
|
|
# thing on every host that reads it back.
|
|
mint_time() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
|
|
# Which storage driver is under this box? The placement contract answers it:
|
|
# every box launches with --profile box-net, and that profile hardcodes the
|
|
# root disk's pool (profiles/box-net.yaml), so the pool is not a guess — it is
|
|
# read back from the profile that actually placed the instance, and only falls
|
|
# back to the name setup-host.sh creates. Two probes for the driver because
|
|
# the tiers see storage differently: 'incus storage show' is the admin read
|
|
# (the same line setup-host.sh:297 prints), 'incus storage list --format csv'
|
|
# is the one a restricted user's incus-user proxy is likelier to answer.
|
|
# Prints the driver, or nothing when neither probe answers.
|
|
storage_driver() {
|
|
local pool driver
|
|
pool="$(incus profile device get box-net root pool 2>/dev/null || true)"
|
|
[ -n "$pool" ] || pool=default
|
|
# '|| true' on all three probes, not just the first. A refusal here is an
|
|
# ANSWER ("this tier cannot read the pool"), never an error — this function
|
|
# must never be the thing that kills a mint. Today the two pipelines are
|
|
# safe only by accident: command substitution strips errexit, so a failing
|
|
# 'incus storage show' under pipefail falls through to the fallback instead
|
|
# of aborting. Add 'shopt -s inherit_errexit' to this file — exactly the
|
|
# robustness tweak #107 describes sailing through review — and that accident
|
|
# reverses into a fatal abort mid-mint, inside the function whose contract
|
|
# is NEVER fatal. Make the three probes read alike and depend on nothing.
|
|
driver="$(incus storage show "$pool" 2>/dev/null | awk '/^driver:/ {print $2; exit}' || true)"
|
|
[ -n "$driver" ] || driver="$(incus storage list --format csv 2>/dev/null \
|
|
| awk -F, -v p="$pool" '$1 == p {print $2; exit}' || true)"
|
|
printf '%s' "$driver"
|
|
}
|
|
|
|
# The shared policy behind every mark box takes for you (#104, #130):
|
|
# read the opt-out, read the storage driver, refuse to double the disk cost on
|
|
# a pool with no copy-on-write, and NEVER fail a mint over a checkpoint.
|
|
#
|
|
# It lives once on purpose. What generalises here is the MECHANISM, and the
|
|
# never-fatal contract inside it is exactly the property that must not be got
|
|
# subtly different in two places — one function, one place to review it, one
|
|
# place a future third mark inherits it from. What does NOT generalise is the
|
|
# PROSE: 'pristine' and 'bootstrapped' name different moments and are worth
|
|
# different sentences, so each wrapper below owns its own narration and passes
|
|
# it in. Two thin wrappers over one policy is the honest split; a single
|
|
# stringly-parameterised function taking five sentences would not be.
|
|
#
|
|
# The opt-out variable name is DERIVED from the label (BOX_SNAPSHOT_PRISTINE,
|
|
# BOX_SNAPSHOT_BOOTSTRAPPED) so the message can never drift from the knob the
|
|
# operator actually has to set.
|
|
#
|
|
# snapshot_mark <instance> <name> <label> <enabled> <what-this-state-is>
|
|
snapshot_mark() {
|
|
local instance="$1" name="$2" label="$3" enabled="$4" what="$5" driver knob
|
|
knob="BOX_SNAPSHOT_$(printf '%s' "$label" | tr '[:lower:]' '[:upper:]')"
|
|
if [ "$enabled" = 0 ]; then
|
|
echo "box: skipping the '$label' snapshot ($knob=0)."
|
|
return 0
|
|
fi
|
|
driver="$(storage_driver)"
|
|
# On btrfs (the deliberate default, host/setup-host.sh:261-298) this costs
|
|
# kilobytes until the guest diverges. On the documented 'dir' fallback
|
|
# (:294) there is no CoW at all: the snapshot is a full copy of the root,
|
|
# several GB and minutes, on EVERY mint. Skip it there — but loudly, and
|
|
# naming the by-hand command, because a silent skip teaches an operator to
|
|
# expect a mark that will not be there. With two marks the objection is
|
|
# twice the size, so the skip covers both: a 'dir' host is not asked to pay
|
|
# for one full root copy per mint, let alone two.
|
|
if [ "$driver" = dir ]; then
|
|
echo "box: NOT taking the '$label' snapshot — this host's storage pool is the 'dir'" >&2
|
|
echo "box: driver, which has no copy-on-write, so the mark would be a full multi-GB copy" >&2
|
|
echo "box: of the root disk on every mint rather than the near-free btrfs one." >&2
|
|
echo "box: take it by hand if you want it here: box snapshot $name $label" >&2
|
|
echo "box: or fix the host (btrfs is what 'box setup-host' installs by default)." >&2
|
|
return 0
|
|
fi
|
|
# Neither probe answered — an unusual host, or a tier that cannot read the
|
|
# pool. Proceed, and say what was assumed. The two mistakes are not
|
|
# symmetric: a snapshot taken on a 'dir' host wastes disk the operator can
|
|
# see in 'box info' and delete, while a snapshot NOT taken is the moment
|
|
# gone for good, which is the whole thing this exists to prevent.
|
|
[ -n "$driver" ] || echo "box: could not read the storage driver — taking '$label' anyway," \
|
|
"assuming copy-on-write ($knob=0 to skip)." >&2
|
|
echo "box: marking '$label' — $what"
|
|
if ! incus snapshot create "$instance" "$label" </dev/null; then
|
|
# An undo is not the mint's product: a mint that worked must not be
|
|
# failed by a checkpoint that didn't.
|
|
echo "box: WARNING — could not take the '$label' snapshot (incus said why above)." >&2
|
|
echo "box: the box is fine; it just has no $label mark. The mint continues." >&2
|
|
return 0
|
|
fi
|
|
marks="$marks $label"
|
|
echo "box: roll back to it any time with: box restore $name $label"
|
|
}
|
|
|
|
# Did THIS run actually create <label>? The only honest source for a message
|
|
# that offers a rollback: see 'marks' above for why the return status cannot
|
|
# answer it.
|
|
mark_taken() { case " $marks " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
|
|
|
# Take the 'pristine' snapshot, or say loudly why not. Default ON: the value
|
|
# of this mark only exists if it is already there on the bad day, and nobody
|
|
# takes it by hand at the one moment it is true. The escape hatch is the
|
|
# BOX_LAUNCH_TIMEOUT shape — an environment knob, not another flag on 'new'.
|
|
snapshot_pristine() {
|
|
snapshot_mark "$1" "$2" pristine "${BOX_SNAPSHOT_PRISTINE:-1}" \
|
|
"the guest after cloud-init, before rig converges anything."
|
|
}
|
|
|
|
# Take the 'bootstrapped' snapshot (#130), after the rig hook box RAN and
|
|
# WATCHED SUCCEED. Same default, same opt-out shape, same never-fatal
|
|
# contract as 'pristine' — it is the same policy, one function up.
|
|
#
|
|
# The label is deliberately CONDITIONAL where 'pristine' is unconditional, and
|
|
# the asymmetry is the point rather than an oversight. 'pristine' marks a
|
|
# MOMENT that every fresh mint has, so it is taken on every fresh mint.
|
|
# 'bootstrapped' marks an EVENT — "a tenant role converged, and nothing has
|
|
# happened since" — and a blank box has no such event. Minting the label there
|
|
# anyway would either duplicate 'pristine' byte for byte (nothing ran between
|
|
# them, so it is the same disk state at twice the cost, which is precisely the
|
|
# doubling the 'dir' skip above exists to refuse) or, worse, assert a
|
|
# convergence that never happened. So a hookless box gets nothing, and the
|
|
# label's presence is itself information: this box had a role and box saw it
|
|
# converge.
|
|
#
|
|
# Read only in that direction. PRESENCE means the mint-time hook succeeded and
|
|
# the box has not been touched since; ABSENCE means nothing at all — a blank
|
|
# template, a 'dir' pool, an opt-out, or a role converged by hand afterwards
|
|
# (see cmd_new's hook-failure path, which hands the operator the command
|
|
# because box cannot honestly take a mark for a run it did not watch). Nothing
|
|
# in box may treat a missing 'bootstrapped' as evidence a box is unconverged.
|
|
snapshot_bootstrapped() {
|
|
snapshot_mark "$1" "$2" bootstrapped \
|
|
"${BOX_SNAPSHOT_BOOTSTRAPPED:-1}" \
|
|
"the tenant role converged, and nothing has touched the box since."
|
|
}
|
|
|
|
cmd_new() {
|
|
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
|
|
require_stack
|
|
local instance; instance="$(iname_of "$name")"
|
|
if [ -n "$from" ]; then
|
|
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
|
|
[ -z "$cpu$memory$disk" ] || usage_error "--cpu/--memory/--disk shape a fresh mint; a clone carries its source's resources ('box incus' can change them afterwards)"
|
|
local src="${from%%/*}" snap="" srcref
|
|
case "$from" in */*) snap="${from#*/}" ;; esac
|
|
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
|
|
incus copy "$srcref" "$instance"
|
|
# 'incus copy' carries every user.* key forward (audit B2) — which is what
|
|
# makes a clone know its template and user for free, and is also why the
|
|
# mint stamp (#103) cannot simply ride along. A clone that inherited the
|
|
# stamp verbatim would claim to have been minted at the source's mint time,
|
|
# by the box version that minted the SOURCE, in a mint that never touched
|
|
# this instance. That is not a stale field, it is a false one.
|
|
#
|
|
# So re-stamp exactly the keys that describe THIS instance's coming into
|
|
# being, and leave the rest alone:
|
|
# · version / created / schema — the clone was made HERE, NOW, by THIS box
|
|
# · origin=clone, origin.from=<srcref> — how, and from what
|
|
# Deliberately NOT re-stamped, because they are lineage and stay true: the
|
|
# clone's disk really did come from that image, that template, that user and
|
|
# that rig role — reading them off the source is the whole point of a clone.
|
|
# ('incus copy' preserves the instance type too, so mode stays true as well.)
|
|
#
|
|
# 'mode.asked' is the one key that sits in NEITHER column, and so it is
|
|
# CLEARED rather than re-stamped or inherited. It is a mint-event fact —
|
|
# only the mint knew whether a container was asked for or fallen back into
|
|
# for want of /dev/kvm — and the asker was the SOURCE's operator. A clone
|
|
# refuses --vm/--container outright (nobody was asked anything here), so an
|
|
# inherited 'asked' makes 'box info' print a demand that was never made of
|
|
# this instance. There is no true value to re-stamp it with: the honest
|
|
# answer is absence, and absence is already how the whole block renders
|
|
# what it does not know — the MODE line simply does not print, while TYPE
|
|
# above still says VM or CT off the preserved instance type.
|
|
#
|
|
# origin.from records ONE hop. A clone of a clone names its parent and
|
|
# forgets its grandparent: the alternative is an unbounded chain in a config
|
|
# value, and the parent is the box an operator can actually go look at.
|
|
incus config set "$instance" \
|
|
user.box.schema="$BOX_STAMP_SCHEMA" \
|
|
user.box.version="$(box_version)" \
|
|
user.box.created="$(mint_time)" \
|
|
user.box.origin=clone \
|
|
user.box.origin.from="$srcref"
|
|
# Cleared, not set-to-empty: an empty value is still a key, and a reader
|
|
# that greps the config would find it. Tolerated failure because the source
|
|
# may predate the stamp and never have carried the key at all — a clone
|
|
# must not die over a key that was already absent.
|
|
incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true
|
|
incus start "$instance"
|
|
wait_agent "$instance"
|
|
reset_identity "$instance"
|
|
echo "box: cloned $srcref — isolation and auth state carry over from the source."
|
|
# A clone gets NO 'pristine' of its own (#104). It skips cloud-init and
|
|
# the rig hook entirely, so the pristine MOMENT never happens here —
|
|
# anything box marked at clone time would be "whatever the source was",
|
|
# converged and worked-in, wearing a label that promises pristine Debian.
|
|
# That is worse than no mark at all, so the mint-path snapshot lives
|
|
# strictly in the fresh-mint branch below and never runs here.
|
|
#
|
|
# What a clone gets instead is INHERITANCE, and it is already correct:
|
|
# 'incus copy <box>' carries the source's snapshots, so a source that has
|
|
# a 'pristine' hands the clone a real one — the true pristine state of
|
|
# the disk this clone descends from. Copying a SNAPSHOT ('--from box/snap')
|
|
# carries no snapshot list, so that clone starts with none. Both are
|
|
# honest; neither is invented. Say which one this is rather than leaving
|
|
# the operator to run 'box info' to find out.
|
|
# Capture first, THEN read (#124's class). Piping a multi-line incus
|
|
# writer straight into an early-exit reader lets grep close the pipe on
|
|
# the first match, SIGPIPE incus, and hand pipefail a 141 — which here
|
|
# would read as "no pristine" and narrate the WRONG inheritance shape on a
|
|
# clone that actually has one. Un-racy today at this writer's size, but
|
|
# the assignment costs nothing and does not depend on that staying true.
|
|
local snaps; snaps="$(incus snapshot list "$instance" --format csv 2>/dev/null || true)"
|
|
if printf '%s\n' "$snaps" | grep -q '^pristine,'; then
|
|
echo "box: it inherited the source's snapshots, 'pristine' among them (box restore $name pristine)."
|
|
else
|
|
echo "box: no 'pristine' mark here — a clone has no pristine moment to capture, and box"
|
|
echo "box: will not label the source's state as one. 'box info $name' lists what it did inherit."
|
|
fi
|
|
else
|
|
local t="${template:-blank}" m extra=()
|
|
load_template "$t"
|
|
m="$(pick_mode)"
|
|
# A server-class template (BOX_REQUIRE_VM, #68) has no weaker mode: the VM
|
|
# is its trust boundary, and its guest runs docker. Refuse the container
|
|
# fallback AND an explicit --container — never silently mint something
|
|
# lesser than what the template promises. The message holds for both
|
|
# tiers: /dev/kvm is a fact about the HOST, and an admin's default-project
|
|
# mint and a restricted user's incus-user mint go through the same daemon,
|
|
# so the fix is the same for both — a KVM-capable host, not a grant.
|
|
if [ "$T_REQUIRE_VM" = 1 ] && [ "$m" != vm ]; then
|
|
[ "$mode" != container ] || usage_error "template '$t' requires VM mode — it will not mint as a container (drop --container)"
|
|
die "template '$t' requires VM mode and this host has no /dev/kvm — mint it on a KVM-capable host (or via --remote)"
|
|
fi
|
|
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
|
|
# security.secureboot=false: Incus defaults VMs to secureboot ON, and a
|
|
# Debian cloud image whose shim is signed with a key the host's OVMF does
|
|
# not trust dies with "bad shim signature / prohibited by secure boot
|
|
# policy" and drops to the GRUB menu forever — the kernel never loads. It
|
|
# is not part of a throwaway box's threat model (the VM boundary is), and
|
|
# turning it off boots reliably across image rebuilds. Container mode has
|
|
# no firmware, so it does not apply there.
|
|
if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); else extra+=(--config security.nesting=true); fi
|
|
# BOX_AUTOSTART (#68): a server-class box must come back after a host
|
|
# reboot without an operator. Stamped per-instance like limits.*; a --from clone
|
|
# needs no code — 'incus copy' keeps every non-volatile config key, the
|
|
# same ride the user.* stamps take (audit B2).
|
|
[ "$T_AUTOSTART" != 1 ] || extra+=(--config boot.autostart=true)
|
|
# Root size is a VM launch concern; a container's root rides the pool. Say
|
|
# so instead of silently dropping an explicit --disk.
|
|
[ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2
|
|
# The template's identity is stamped ONTO the instance: which template,
|
|
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
|
# knows what it is without ever consulting the template again.
|
|
#
|
|
# And the rest of what this line knows and used to drop on the floor (#103).
|
|
# There is no host-side per-box store — the Incus instance config IS the
|
|
# database — so a fact not written here is simply gone the moment the mint
|
|
# returns. The stamp describes the MINT, not the outcome: it lands before
|
|
# cloud-init and before rig, and nothing later edits it.
|
|
#
|
|
# schema the stamp's shape, so a future reader knows what it is holding
|
|
# version the box that minted it — 'box --version' is a fact about the
|
|
# binary in front of you, never about the box you are looking at
|
|
# image the alias asked for. It is an UNPINNED alias on a moving
|
|
# remote: two boxes minted a month apart from "the same
|
|
# template" are not the same box, and the alias alone cannot
|
|
# say so. What it resolved to is pinned after the launch below.
|
|
# mode what it minted as, and what was ASKED — a container that fell
|
|
# back for want of /dev/kvm and one the operator asked for read
|
|
# identically afterwards, and only the mint knew which
|
|
# role the rig role box auto-runs at the hook below
|
|
# rig.* WHICH rig converged it, stamped only for a seed that actually
|
|
# installs rig from the pin ('blank' seeds none, so it gets none)
|
|
# created when. See mint_time() for why a timestamp belongs here.
|
|
# origin mint. A clone re-stamps it (see the --from branch above).
|
|
#
|
|
# NOT stamped, on purpose: cpu/memory (limits.* already hold them, and a
|
|
# duplicate drifts the first time someone edits the limit by hand); disk
|
|
# (a VM's is the root device size, and a container's does not exist — its
|
|
# root rides the pool, so a stamped value would be fiction); and tier,
|
|
# which box_tier() derives from whoever is ASKING, not from the box.
|
|
local stamp=(
|
|
--config user.box.schema="$BOX_STAMP_SCHEMA"
|
|
--config user.box.version="$(box_version)"
|
|
--config user.box.image="$T_IMAGE"
|
|
--config user.box.mode="$m"
|
|
--config user.box.mode.asked="$mode"
|
|
--config user.box.created="$(mint_time)"
|
|
--config user.box.origin=mint
|
|
)
|
|
[ -z "$T_BOOTSTRAP_ROLE" ] || stamp+=(--config user.box.role="$T_BOOTSTRAP_ROLE")
|
|
if grep -q '@RIG_REPO@' "$root/templates/$t/user-data.yaml" 2>/dev/null; then
|
|
stamp+=(--config user.box.rig.repo="$(rig_repo)" --config user.box.rig.ref="$(rig_ref)")
|
|
fi
|
|
#
|
|
# The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
|
|
# release drill the child 'incus launch' wedged before the create was
|
|
# even accepted — 'incus operation list' empty, the instance never
|
|
# existed, the daemon journal quiet — once for 56 minutes until killed
|
|
# by hand. Without a line here that wedge reads exactly like a cold mint
|
|
# working; without a budget it lasts forever. Ten minutes is generous —
|
|
# the coldest measured mint (first VM on a fresh pool, see wait_agent)
|
|
# is minutes, never an hour — and BOX_LAUNCH_TIMEOUT (seconds) overrides
|
|
# it, the same scripting knob shape as BOX_CPU / BOX_MEMORY. The drill's
|
|
# lore applies verbatim (RUNS.md trap 13): 'timeout -k' so a launch that
|
|
# shrugs off TERM still dies, and stdin pinned like every other
|
|
# non-interactive incus call — only shell/exec/tmux may own the terminal.
|
|
local budget="${BOX_LAUNCH_TIMEOUT:-600}" rc=0
|
|
echo "box: launching instance $instance (incus launch, $m mode)..."
|
|
timeout -k 5 "$budget" incus launch "$T_IMAGE" "$instance" --profile box-net \
|
|
--config user.box=1 \
|
|
--config user.box.template="$t" \
|
|
--config user.box.user="$T_USER" \
|
|
"${stamp[@]}" \
|
|
--config limits.cpu="$T_CPU" \
|
|
--config limits.memory="$T_MEMORY" \
|
|
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
|
|
"${extra[@]}" </dev/null || rc=$?
|
|
# 124 = the budget fired (TERM landed); 137 = the -k KILL was needed —
|
|
# or, on 137, something external (an OOM kill) beat the budget to it.
|
|
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
|
|
echo >&2
|
|
# timeout only proves the CLIENT overran the budget. 'incus launch' is
|
|
# create-then-start, so a slow-but-progressing launch (first mint
|
|
# pulling an uncached image, say) may already have REGISTERED the
|
|
# instance — in which case "never created, retry" would be exactly
|
|
# wrong: the retry collides with 'Instance already exists'. Probe, say
|
|
# which case this is, and best-effort delete either way (a no-op on
|
|
# the true #93 wedge, the cleanup on an overrun; also covers a create
|
|
# that lands in the race between the probe and the delete) so the
|
|
# retry advice below is safe in BOTH worlds. Review consensus on the
|
|
# first round of #94: all three reviewers converged on this hole.
|
|
if timeout -k 5 15 incus info "$instance" </dev/null >/dev/null 2>&1; then
|
|
echo "box: 'incus launch' OVERRAN its ${budget}s budget (killed) — but the instance WAS" >&2
|
|
echo "box: registered: this looks like a slow launch, not the #93 client wedge. Removing" >&2
|
|
echo "box: the partial instance so a retry starts clean..." >&2
|
|
else
|
|
echo "box: 'incus launch' WEDGED — killed after ${budget}s (or killed from outside), and" >&2
|
|
echo "box: the instance was never created. This is the #93 failure: the incus client" >&2
|
|
echo "box: hangs with NO server-side operation ('incus operation list' is empty, the" >&2
|
|
echo "box: daemon journal is quiet). An immediate retry of the exact same 'box new' has" >&2
|
|
echo "box: been observed to succeed, both times it was measured." >&2
|
|
fi
|
|
timeout -k 5 30 incus delete --force "$instance" </dev/null >/dev/null 2>&1 || true
|
|
echo "box: if it persists, diagnose the host: box doctor" >&2
|
|
echo "box: (a genuinely slower mint can raise the budget: BOX_LAUNCH_TIMEOUT=<seconds>)" >&2
|
|
die "incus launch did not finish inside ${budget}s — retry the same command (#93)"
|
|
elif [ "$rc" -ne 0 ]; then
|
|
# Not a wedge: incus refused and said why on stderr, right above.
|
|
die "incus launch failed (exit $rc)"
|
|
fi
|
|
# The one field the launch line could not know: 'user.box.image' above is
|
|
# the ALIAS, and an alias on a moving remote is not a reproducible fact.
|
|
# Incus resolves it during the launch and records what it landed on in
|
|
# volatile.base_image — read it back and pin it into the stamp, so an
|
|
# incident six months from now can ask "was this box built on the image
|
|
# that broke?" and get an answer instead of a template name.
|
|
#
|
|
# Best-effort BY CONSTRUCTION, and that is the whole design of this line:
|
|
# it runs only after a launch that already succeeded, and a box that exists
|
|
# and boots must never be failed over a provenance field. Every failure
|
|
# here is silent and leaves the alias standing as the honest partial answer
|
|
# — a stamp with no fingerprint, which is exactly how cmd_info renders it.
|
|
local fp; fp="$(incus config get "$instance" volatile.base_image 2>/dev/null || true)"
|
|
[ -z "$fp" ] || incus config set "$instance" user.box.image.fingerprint="$fp" >/dev/null 2>&1 || true
|
|
wait_agent "$instance"
|
|
echo "box: waiting for phase-1 (cloud-init)..."
|
|
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
|
|
# A failed cloud-init used to print a screen of dots and the word "error",
|
|
# with nothing to act on — the box's own log holds the reason, and nobody
|
|
# was told it existed. Show it, and leave the box up to inspect.
|
|
# Every non-interactive exec pins stdin. With a TTY on stdin, 'incus exec'
|
|
# goes interactive — and when box's own output is redirected (a script, the
|
|
# drill), the session can wedge open after the remote command has exited,
|
|
# blocking forever on a websocket that will never close. Caught live: a
|
|
# mint stuck at 'status: done'. Only shell/exec/tmux may own the terminal.
|
|
# PYTHONUNBUFFERED: cloud-init's progress dots are block-buffered the
|
|
# moment stdout is not a tty — a redirected mint (a script, the drill)
|
|
# shows NOTHING for the whole install and then one burst at the end,
|
|
# which reads exactly like a hang. Unbuffered, the dots arrive as dots.
|
|
if ! incus exec "$instance" -- env PYTHONUNBUFFERED=1 cloud-init status --wait </dev/null; then
|
|
echo >&2
|
|
echo "box: cloud-init FAILED in $name. What it says:" >&2
|
|
incus exec "$instance" -- cloud-init status --long </dev/null 2>&1 | sed 's/^/ /' >&2
|
|
echo >&2
|
|
echo "box: the errors, from the box's log:" >&2
|
|
incus exec "$instance" -- sh -c \
|
|
"grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \
|
|
</dev/null 2>/dev/null | sed 's/^/ /' >&2
|
|
echo >&2
|
|
echo "box: '$name' is still up — inspect it, then delete it:" >&2
|
|
echo " box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
|
|
echo " box rm $name" >&2
|
|
echo "box: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2
|
|
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
|
|
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
|
|
fi
|
|
# The pristine mark (#104, child of rig#62). RIGHT HERE — cloud-init has
|
|
# finished and rig has not started — the guest is pristine Debian plus
|
|
# box's seed, and nothing else. That state exists for a few seconds on
|
|
# every mint and has never been captured; rig#62's "back to pristine
|
|
# Debian" is exactly this state, so take it while it is true.
|
|
#
|
|
# Unconditional within a fresh mint, not gated on T_BOOTSTRAP_ROLE: a
|
|
# blank box has no rig hook but it does have the same pristine moment,
|
|
# and 'box restore <box> pristine' should mean one thing on every box.
|
|
#
|
|
# NEVER fatal. This is an undo, not the mint's product — a mint that
|
|
# worked must not be failed by a checkpoint that didn't.
|
|
snapshot_pristine "$instance" "$name"
|
|
# The tenant convergence (#81): the seed above is thin — the user, tmux,
|
|
# rig — and what the box BECOMES is rig's job. A template that names a
|
|
# bootstrap role gets it auto-run here, as root inside the guest, because
|
|
# the tenant roles are creds-free and non-interactive BY CONTRACT
|
|
# (rig#31): no prompts, no tailnet, no keys — nothing that joins or
|
|
# admits may ever ride this hook. The creds-holding steps (staging's
|
|
# workload join) stay operator-run through 'box shell', and the role is
|
|
# idempotent, so a failed or interrupted run is re-runnable as-is.
|
|
if [ -n "$T_BOOTSTRAP_ROLE" ]; then
|
|
echo "box: converging the tenant — rig bootstrap $T_BOOTSTRAP_ROLE (rig's own narration follows)..."
|
|
if ! incus exec "$instance" -- rig bootstrap "$T_BOOTSTRAP_ROLE" </dev/null; then
|
|
echo >&2
|
|
echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
|
|
echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
|
|
echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
|
|
# The mark box cannot take for you (#130), said HERE because here is
|
|
# the only moment the operator is looking. 'bootstrapped' means "box
|
|
# ran the hook and watched it succeed" — a by-hand re-run happens in a
|
|
# shell box does not watch, so box cannot know when (or whether) it
|
|
# converged, and a mark taken on a guess would be a label asserting a
|
|
# convergence box never saw. That is the same lie #104 refused on the
|
|
# clone path. So: hand over the command instead of inventing the fact.
|
|
echo "box: no 'bootstrapped' mark was taken — box only marks a hook it watched succeed," >&2
|
|
echo " and a by-hand re-run happens in a shell it does not watch. Take it yourself once" >&2
|
|
echo " the role converges, at the moment it is true:" >&2
|
|
echo " box snapshot $name bootstrapped" >&2
|
|
# Only offer the rollback that EXISTS. Three mints reach this line
|
|
# with no pristine mark, all by this stack's own design: a 'dir' pool
|
|
# (skipped), BOX_SNAPSHOT_PRISTINE=0, and a refused create (warned,
|
|
# mint continued). On a 'dir' host that is EVERY hook failure, and an
|
|
# operator standing at the one moment this message exists for would
|
|
# copy-paste a restore that errors. Offering a mark the disk does not
|
|
# have is the same lie this PR refuses one message earlier, when it
|
|
# declines to invent a 'bootstrapped' it never watched.
|
|
if mark_taken pristine; then
|
|
echo "box: 'box restore $name pristine' is still there if you would rather start the role over." >&2
|
|
fi
|
|
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
|
|
fi
|
|
# The hook RAN and box WATCHED it succeed — so right here the box is
|
|
# converged and untouched, the state an operator re-mints ten minutes
|
|
# for when they break a tenant role but not the OS under it. Never
|
|
# fatal, same as 'pristine': a mint that worked is not failed by a
|
|
# checkpoint that didn't.
|
|
snapshot_bootstrapped "$instance" "$name"
|
|
fi
|
|
fi
|
|
# The login hint belongs to the claude-box template — read the EFFECTIVE
|
|
# template off the instance, so a clone of a claude-box box gets it too and
|
|
# a blank box is not told to run a binary it doesn't have.
|
|
#
|
|
# Both spellings match, and that is not an alias for the ROLE. The role
|
|
# names are a hard cut (rig#76) — 'rig bootstrap claude' is gone, so the
|
|
# seeds ask for 'claude-box' and nothing here softens that. What these arms
|
|
# read is user.box.template, a stamp left on an INSTANCE at its own mint
|
|
# time: every box minted before the rename carries the bare name forever,
|
|
# and a clone carries it forward. Refusing the old spelling here would not
|
|
# cut anything over, it would only drop the hint on boxes that predate the
|
|
# rename — the same reason user.claudebox is honored everywhere else.
|
|
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
|
|
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
|
|
if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
|
|
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
|
|
elif [ "$eff" = staging-box ] || [ "$eff" = staging ]; then
|
|
echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
|
|
echo " box shell $name # then: sudo rig bootstrap workload-server --hostname $name"
|
|
else
|
|
echo "box: ready — 'box shell $name'."
|
|
fi
|
|
}
|
|
|
|
# Boxes are ordinary Incus instances tagged user.box=1 — that tag is the only
|
|
# thing that makes them ours, so every read below is filtered by it and we
|
|
# never report on (or touch) an instance box didn't mint. Pre-rename boxes
|
|
# carry user.claudebox=1 instead and are ours forever; a box can't hold both
|
|
# tags via any path we mint, but the dedupe costs nothing.
|
|
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a
|
|
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
|
|
# several addresses and Incus quotes them across lines. It's fetched separately.)
|
|
boxes_csv() {
|
|
{
|
|
incus list ${remote:+"$remote"} "user.box=1" --format csv --columns nstS
|
|
incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
|
|
} 2>/dev/null | awk -F, '!seen[$1]++'
|
|
}
|
|
|
|
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
|
|
incus list "$1" --format csv --columns 4 2>/dev/null \
|
|
| tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
|
|
| grep . || echo "-"
|
|
}
|
|
|
|
# The box's address ON BOXNET — which is NOT the same as "its first address".
|
|
# A box running docker also carries 172.17.0.1 (docker0), and Incus happily
|
|
# lists that FIRST. box_ipv4() hands you the decoy, and pointing anything at it
|
|
# is pointing at the wrong interface: 'box expose' did exactly that until Incus
|
|
# refused with `Connect IP "172.17.0.1" must be one of the instance's static
|
|
# IPv4 addresses`. The drill has known this trap since run 4; the CLI had not.
|
|
# Derive the prefix from the network rather than hardcoding it.
|
|
box_net_ip() {
|
|
local pfx
|
|
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
|
|
[ -n "$pfx" ] || return 1
|
|
incus list "$1" --format csv --columns 4 2>/dev/null \
|
|
| tr -d '"' | tr ' ,' '\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
|
|
}
|
|
|
|
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
|
|
short_type() {
|
|
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
|
virtual-machine|virtualmachine) echo VM ;;
|
|
container) echo CT ;;
|
|
*) echo "$1" ;;
|
|
esac
|
|
}
|
|
|
|
list_all() {
|
|
local rows; rows="$(boxes_csv)"
|
|
if [ -z "$rows" ]; then
|
|
echo "box: no boxes yet — create one with: box new --name work" >&2
|
|
return 0
|
|
fi
|
|
{
|
|
echo "NAME,STATE,TYPE,SNAPSHOTS"
|
|
while IFS=, read -r n s t snaps; do
|
|
[ -n "$n" ] || continue
|
|
echo "$n,${s:--},$(short_type "$t"),${snaps:-0}"
|
|
done <<<"$rows"
|
|
} | awk -F, '
|
|
{ for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR }
|
|
END { for (r = 1; r <= n; r++) { line = ""
|
|
for (i = 1; i <= 4; i++) line = line sprintf("%-*s ", w[i], cell[r, i])
|
|
sub(/ +$/, "", line); print line } }'
|
|
}
|
|
|
|
# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
|
|
# guess we can answer, not a surprise: point at the command that does want one.
|
|
cmd_list() {
|
|
if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
|
|
die "list takes no box — for one box, use: box info ${args[0]}"
|
|
fi
|
|
if [ "$json" -eq 1 ]; then
|
|
incus list ${remote:+"$remote"} "user.box=1" --format json
|
|
else
|
|
list_all
|
|
fi
|
|
}
|
|
|
|
# One instance config key. 'incus config get' on an UNSET key prints empty and
|
|
# exits 0 (audit B4) — so the '|| true' here covers the daemon refusing, not the
|
|
# key being absent, and every caller below reads absence as an empty string.
|
|
box_cfg() { incus config get "$1" "$2" 2>/dev/null || true; }
|
|
|
|
# The mint stamp (#103), read back for 'box info'. Every key is optional and so
|
|
# is the whole block: a box minted before the stamp existed carries none of it
|
|
# and must render as a box with blanks — never as an error, and never as a box
|
|
# wearing a mint time it does not have. Legacy boxes are not a transitional
|
|
# case: a box outlives the release that minted it, which is exactly what the
|
|
# legacy 'user.claudebox' tag already says out loud at resolve_box.
|
|
box_provenance() {
|
|
local i="$1" schema created ver img fp m asked tpl u role rrepo rref origin from
|
|
local imp impby impl implby impn
|
|
schema="$(box_cfg "$i" user.box.schema)"
|
|
created="$(box_cfg "$i" user.box.created)"; ver="$(box_cfg "$i" user.box.version)"
|
|
img="$(box_cfg "$i" user.box.image)"; fp="$(box_cfg "$i" user.box.image.fingerprint)"
|
|
m="$(box_cfg "$i" user.box.mode)"; asked="$(box_cfg "$i" user.box.mode.asked)"
|
|
tpl="$(box_cfg "$i" user.box.template)"; u="$(box_cfg "$i" user.box.user)"
|
|
role="$(box_cfg "$i" user.box.role)"
|
|
rrepo="$(box_cfg "$i" user.box.rig.repo)"; rref="$(box_cfg "$i" user.box.rig.ref)"
|
|
origin="$(box_cfg "$i" user.box.origin)"; from="$(box_cfg "$i" user.box.origin.from)"
|
|
# The import event (#131) — a THIRD fact, read from its own keys. 'origin'
|
|
# above still says mint or clone, which is what it has always meant.
|
|
imp="$(box_cfg "$i" user.box.imported)"; impby="$(box_cfg "$i" user.box.imported.by)"
|
|
impl="$(box_cfg "$i" user.box.imported.last)"
|
|
implby="$(box_cfg "$i" user.box.imported.last.by)"
|
|
impn="$(box_cfg "$i" user.box.imported.count)"
|
|
# A pre-rename box has no metadata at all but is always a Claude box — the
|
|
# same mapping box_user() makes, for the same reason.
|
|
[ -n "$tpl" ] || [ "$(box_cfg "$i" user.claudebox)" != 1 ] || { tpl=claude; u="${u:-claude}"; }
|
|
|
|
echo
|
|
if [ -n "$created" ] || [ -n "$ver" ]; then
|
|
printf '%-11s%s\n' MINTED "${created:-(time not recorded)} by box ${ver:-unknown}"
|
|
else
|
|
printf '%-11s%s\n' MINTED "(not recorded — this box predates the mint stamp)"
|
|
fi
|
|
# Directly under MINTED, because adjacency is what defeats the misreading:
|
|
# the mint time above is the ARTIFACT's, carried across the trip on purpose,
|
|
# and a reader who meets it alone will take it for this host's.
|
|
#
|
|
# The qualifier states the one thing box actually knows — the ORDERING. It
|
|
# does not say "on another host": a box can be exported and re-imported onto
|
|
# the SAME host (that is the installer's own upgrade advice, #66), and
|
|
# nothing on the box records which host minted it. Inventing a location here
|
|
# would be precisely the fabricated fact the stamp exists to avoid.
|
|
if [ -n "$impl" ] || [ -n "$imp" ]; then
|
|
printf '%-11s%s\n' IMPORTED \
|
|
"${impl:-$imp} by box ${implby:-${impby:-unknown}} (the mint above predates it)"
|
|
# The birth of the import history, shown only when it is a DIFFERENT trip
|
|
# from the latest — on a single import both pairs hold the same values and
|
|
# a second line would be noise. Continuation indent, no second label.
|
|
if [ -n "$imp" ] && [ "$imp" != "${impl:-$imp}" ]; then
|
|
printf '%-11s%s\n' '' "import ${impn:-?} — the first was $imp by box ${impby:-unknown}"
|
|
fi
|
|
fi
|
|
if [ -n "$tpl" ]; then
|
|
local paren=""
|
|
[ -z "$u" ] || paren="user $u"
|
|
[ -z "$role" ] || paren="${paren:+$paren, }role $role"
|
|
printf '%-11s%s\n' TEMPLATE "$tpl${paren:+ ($paren)}"
|
|
fi
|
|
[ -z "$img" ] || printf '%-11s%s\n' IMAGE "$img${fp:+ @ ${fp:0:12}…}"
|
|
# The mode is only worth a line alongside what was ASKED: TYPE above already
|
|
# says VM or CT, but only the mint knew whether a container was chosen or
|
|
# fallen back into for want of /dev/kvm.
|
|
[ -z "$asked" ] || printf '%-11s%s\n' MODE "${m:-?} (asked: $asked)"
|
|
[ -z "$rrepo" ] || printf '%-11s%s\n' RIG "$rrepo@${rref:-?}"
|
|
[ -z "$origin" ] || printf '%-11s%s\n' ORIGIN "$origin${from:+ of $from}"
|
|
# A schema box does not recognise is NEWER than box, not broken: show what is
|
|
# understood and say so, rather than refusing to describe a box that a later
|
|
# release minted perfectly well. (A non-integer lands here too, which is the
|
|
# right side to fail on.)
|
|
if [ -n "$schema" ] && { ! [ "$schema" -eq "$schema" ] 2>/dev/null || [ "$schema" -gt "$BOX_STAMP_SCHEMA" ]; }; then
|
|
printf '%-11s%s\n' NOTE "stamp schema '$schema' is newer than this box ($(box_version)) reads ($BOX_STAMP_SCHEMA) — showing what it understands"
|
|
fi
|
|
}
|
|
|
|
cmd_info() {
|
|
local box="${args[0]}" row
|
|
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
|
|
|
|
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
|
|
[ -n "$row" ] || die "no such box: $box (see 'box list')"
|
|
|
|
local state type snaps
|
|
IFS=, read -r _ state type snaps <<<"$row"
|
|
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
|
|
IPV4 "$(box_ipv4 "$inst")"
|
|
|
|
# A box with a hole says so — an exposure visible only to --list is a hole
|
|
# info would deny. One line per open door.
|
|
local d listen
|
|
while IFS= read -r d; do
|
|
case "$d" in expose-*) : ;; *) continue ;; esac
|
|
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
|
|
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
|
|
done < <(incus config device list "$inst" 2>/dev/null)
|
|
|
|
# What built this box, from what, when, with which box (#103). Nothing else
|
|
# on the host records it — the instance config IS the store — so a stamp
|
|
# nothing surfaces is a stamp nobody has. 'box info --json' carries the keys
|
|
# for free: 'incus list --format json' includes config verbatim.
|
|
box_provenance "$inst"
|
|
|
|
echo
|
|
case "${snaps:-0}" in
|
|
''|0)
|
|
echo "SNAPSHOTS (none)"
|
|
echo
|
|
echo "Take one: box snapshot $box authed"
|
|
return 0 ;;
|
|
esac
|
|
echo "SNAPSHOTS"
|
|
local first="" sname taken
|
|
while IFS=, read -r sname taken _; do
|
|
[ -n "$sname" ] || continue
|
|
[ -n "$first" ] || first="$sname"
|
|
printf ' %-14s%s\n' "$sname" "$taken"
|
|
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
|
|
echo
|
|
echo "Clone one: box new --name <new> --from $box/${first:-<snapshot>}"
|
|
}
|
|
|
|
# Which user does a shell land in? The template stamped it on the instance at
|
|
# mint time (user.box.user), and 'incus copy' carries user.* keys — so a clone
|
|
# knows without consulting the template. Two subtleties, both from the audit:
|
|
# 'incus config get' prints EMPTY + exit 0 for an unset key (B4), hence ${u:-},
|
|
# never '||'; and a pre-rename box has no metadata but is always a Claude box,
|
|
# so the legacy tag maps to 'claude'. The root fallback is effectively
|
|
# unreachable (every template sets a user) — anything that truly needs root
|
|
# goes through the 'box incus' escape hatch.
|
|
box_user() {
|
|
local u
|
|
u="$(incus config get "$1" user.box.user 2>/dev/null || true)"
|
|
if [ -z "$u" ] && [ "$(incus config get "$1" user.claudebox 2>/dev/null || true)" = 1 ]; then
|
|
u=claude
|
|
fi
|
|
echo "${u:-root}"
|
|
}
|
|
|
|
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
|
|
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
|
|
|
|
# A shell is a child of the exec connection: drop the terminal and everything
|
|
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
|
|
# attaches when the session exists and creates it when it doesn't, so starting
|
|
# work and reattaching after a disconnect are the same command. 'shell' stays
|
|
# bare on purpose — two verbs, two contracts.
|
|
cmd_tmux() {
|
|
local session="${args[1]:-main}"
|
|
case "$session" in
|
|
*[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
|
|
esac
|
|
incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
|
|
}
|
|
|
|
cmd_snapshot() {
|
|
local label="${args[1]:-manual-$(date +%s)}"
|
|
incus snapshot create "$inst" "$label"
|
|
echo "$label"
|
|
}
|
|
|
|
cmd_status() {
|
|
echo "box: 'status' is deprecated — use 'box list'." >&2
|
|
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
|
|
# The import EVENT (#131). Everything above either restores the ARTIFACT's
|
|
# truth or re-establishes THIS host's boundary; none of it records that the
|
|
# trip happened. The mint stamp (#103) rides inside the artifact verbatim —
|
|
# which is correct, mint facts belong to the originating host — and that is
|
|
# exactly what creates the gap: an imported box is otherwise indistinguishable
|
|
# from one minted here at the artifact's mint time.
|
|
#
|
|
# Deliberately NOT 'origin=import'. 'origin' answers how this instance came
|
|
# into BEING — mint or clone — and overwriting it destroys that answer: a
|
|
# clone that was exported and imported would come back claiming to be an
|
|
# import, with nothing left saying it was ever a clone and an 'origin.from'
|
|
# naming a lineage no key explains any more. The import is a THIRD fact,
|
|
# orthogonal to the first two, so it takes its OWN keys, and every key the
|
|
# artifact carried — origin, origin.from, created, version, template, user,
|
|
# image, role, rig.* — is left exactly as it arrived.
|
|
#
|
|
# Birth pair + latest pair, the shape heavy-duty/rig#61 settled on for the
|
|
# same repeated-event question. It transfers: a box can make the trip more
|
|
# than once, and these keys ride inside the artifact on every subsequent one.
|
|
# · imported / imported.by FIRST-wins. Pinned once, never rewritten.
|
|
# · imported.last / .last.by LAST-wins. When it landed HERE.
|
|
# · imported.count how many trips — first and last alone
|
|
# cannot tell 2 apart from 5, and nothing
|
|
# else on the box can reconstruct it.
|
|
# Last-wins alone would repeat, one level down, the very mistake this key
|
|
# exists to avoid: the second import would erase the evidence of the first.
|
|
# First-wins alone cannot answer "when did this land on THIS host", which is
|
|
# the question an operator actually asks.
|
|
local first_at prev_n n now
|
|
first_at="$(incus config get "$target" user.box.imported 2>/dev/null || true)"
|
|
prev_n="$(incus config get "$target" user.box.imported.count 2>/dev/null || true)"
|
|
# A count that is absent (the first trip) or not an integer (a hand-edited
|
|
# config, a foreign user.box.imported.count) must never fail an import that
|
|
# has already happened — arithmetic on it under 'set -e' would. Same side the
|
|
# schema check in box_provenance() falls on: degrade, never die.
|
|
# Two holes, and they have to close together. `[ 08 -eq 08 ]` PASSES — test
|
|
# parses decimal — and then `$((08 + 1))` aborts with "value too great for
|
|
# base", because arithmetic reads a leading zero as octal. That abort lands
|
|
# after the physical 'incus import' and before the stamp, the placement fix
|
|
# and the start: precisely the window this guard exists to protect. So the
|
|
# digits-only case closes sign and garbage, and 10# forces base ten.
|
|
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
|
|
n=$((10#$prev_n + 1)); now="$(mint_time)"
|
|
local istamp=(
|
|
user.box.imported.last="$now"
|
|
user.box.imported.last.by="$(box_version)"
|
|
user.box.imported.count="$n"
|
|
)
|
|
# The birth of this box's import history: pinned on the first trip and
|
|
# carried by the artifact forever after, so a box on its third host still
|
|
# names the trip that started it.
|
|
[ -n "$first_at" ] || istamp+=(user.box.imported="$now" user.box.imported.by="$(box_version)")
|
|
# No 'user.box.schema' is written here, on either path. "Absent schema means
|
|
# pre-stamp" is load-bearing for the MINTED line, and stamping schema=1 onto
|
|
# a legacy artifact that carries no mint stamp would claim a shape it does
|
|
# not have. These keys need no schema to be read — every reader tolerates
|
|
# absence by construction. Nor does the schema BUMP: #103's contract is that
|
|
# it moves on removal or repurposing, never on addition, and this only adds.
|
|
#
|
|
# Before the start, like the clone re-stamp and for the same reason: an
|
|
# imported box is never observable — not for a second, not by a concurrent
|
|
# 'box info' — without the record of how it got here.
|
|
incus config set "$target" "${istamp[@]}"
|
|
# 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
|
|
# 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
|
|
# fault first: each one has killed a cold mint or silently weakened a
|
|
# boundary. The install tree ships the whole repo, so delegate — one
|
|
# hardened script, two audiences.
|
|
cmd_doctor() {
|
|
local script="$root/drill/doctor.sh"
|
|
[ -f "$script" ] || die "doctor script not found at $script — re-run install.sh"
|
|
# The doctor's verdict depends on who is asking: a restricted user cannot
|
|
# see the nft tables or the kernel's bridge state, and telling them the
|
|
# host is broken because THEY cannot read it would be a wrong diagnosis.
|
|
export BOX_TIER; BOX_TIER="$(box_tier)"
|
|
exec bash "$script" "${args[@]}"
|
|
}
|
|
|
|
# The host lifecycle scripts, as first-class verbs — nobody should have to know
|
|
# where the install tree keeps its scripts. Each execs the installed script
|
|
# with its flags passed through; the script owns its own behavior (setup-host's
|
|
# incus-admin re-login dance, teardown's confirmation, migrate's per-box work).
|
|
host_script() { # $1 = script basename under host/
|
|
local script="$root/host/$1"
|
|
[ -f "$script" ] || die "$1 not found at $script — re-run install.sh"
|
|
exec bash "$script" "${args[@]}"
|
|
}
|
|
cmd_setup_host() { host_script setup-host.sh; }
|
|
cmd_teardown_host() { host_script teardown-host.sh; }
|
|
cmd_migrate_host() { host_script migrate-host.sh; }
|
|
cmd_grant() { host_script grant-user.sh; }
|
|
cmd_revoke() { host_script revoke-user.sh; }
|
|
|
|
# --- the versioned install (#66's stance, made livable in 0.7.0) ------------
|
|
# install.sh lands each version at <install-root>/versions/<v>, with a
|
|
# 'current' symlink naming the default and $BINDIR/box pointing through it.
|
|
# $root (readlink -f, line 8) already resolved the whole chain, so a versioned
|
|
# install always runs from .../versions/<v> — and a git checkout does not,
|
|
# which is how these verbs know to refuse instead of uninstalling somebody's
|
|
# working copy.
|
|
install_root() {
|
|
local vdir; vdir="$(dirname "$root")"
|
|
[ "$(basename "$vdir")" = versions ] || return 1
|
|
dirname "$vdir"
|
|
}
|
|
|
|
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
|
|
# gate for every caller that builds a path from one (the installer's new_ver,
|
|
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
|
|
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
|
|
# spaces and option-lookalikes by construction — a crafted version dies HERE,
|
|
# never in an rm -rf or an ln. install.sh carries a byte-identical copy;
|
|
# test/cli.sh diffs the two so the gates cannot drift.
|
|
valid_version() {
|
|
case "$1" in
|
|
''|.*|-*) return 1 ;;
|
|
*[!A-Za-z0-9._+-]*) return 1 ;;
|
|
esac
|
|
return 0
|
|
}
|
|
|
|
# Which boxes exist on this host, at THIS caller's tier? Prints their names
|
|
# (both tag generations) and succeeds when at least one exists; fails when
|
|
# none are visible — including when incus is absent or not answering, because
|
|
# #66's stance protects BOXES from a version change, and a daemon that cannot
|
|
# answer has none to protect. install.sh carries a byte-identical copy (it
|
|
# runs before any install tree exists); test/cli.sh diffs the two so they
|
|
# cannot drift.
|
|
existing_boxes() {
|
|
command -v incus >/dev/null 2>&1 || return 1
|
|
{ timeout 10 incus list user.box=1 --format csv --columns n </dev/null
|
|
timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
|
|
} 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
|
|
}
|
|
|
|
# #66, kept at flip time: never change (or remove) the version under existing
|
|
# boxes. Names every box and the remedy, then dies — a refusal that does not
|
|
# say which boxes block it sends the operator off to rediscover 'box list'.
|
|
die_under_boxes() { # $1 = the act being refused, $2 = the retry command
|
|
local names n
|
|
names="$(existing_boxes)" || return 0
|
|
{
|
|
echo "box: this host has existing boxes:"
|
|
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: preserve what you care about — 'box down <box>', 'box export <box>' (one"
|
|
echo "box: portable file per box, #70) — then 'box rm <box>' each, and re-run: $2"
|
|
} >&2
|
|
exit 1
|
|
}
|
|
|
|
# The PATH symlinks that could ride this install: the one this invocation came
|
|
# in on, BOX_BIN's, and the tier default's. Candidates only — every consumer
|
|
# checks where a link actually points before touching it, so a symlink that is
|
|
# somebody else's (another install root, a hand-rolled wrapper) is never moved.
|
|
bin_links() {
|
|
local c=()
|
|
[ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}")
|
|
[ -n "${BOX_BIN:-}" ] && c+=("$BOX_BIN/box")
|
|
if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/box); else c+=("$HOME/.local/bin/box"); fi
|
|
printf '%s\n' "${c[@]}" | awk '!seen[$0]++'
|
|
}
|
|
|
|
converge_bin_links() { # $1 = install root: point our PATH symlinks through current
|
|
local ir="$1" p t
|
|
while IFS= read -r p; do
|
|
[ -L "$p" ] || continue
|
|
t="$(readlink -f "$p" 2>/dev/null || true)"
|
|
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
|
|
case "$t" in
|
|
"$ir"/*) ln -sfn "$ir/current/bin/box" "$p" ;;
|
|
esac
|
|
done < <(bin_links)
|
|
}
|
|
|
|
cmd_versions() {
|
|
local ir cur d v mark
|
|
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to list"
|
|
cur="$(readlink -f "$ir/current" 2>/dev/null || true)"
|
|
echo "VERSIONS ($ir)"
|
|
for d in "$ir/versions"/*/; do
|
|
[ -d "$d" ] || continue
|
|
v="$(basename "$d")"
|
|
mark=""
|
|
[ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)"
|
|
[ "$(readlink -f "$d")" = "$root" ] && mark="$mark (running)"
|
|
printf ' %s%s\n' "$v" "$mark"
|
|
done
|
|
echo
|
|
echo "switch the default: box use <version>"
|
|
echo "install another: re-run install.sh (versions land side by side)"
|
|
}
|
|
|
|
cmd_use() {
|
|
local v="${args[0]:-}" ir eff expect out
|
|
[ -n "$v" ] || usage_error "usage: $(synopsis_of use)"
|
|
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to switch"
|
|
valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'box versions')"
|
|
[ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')"
|
|
die_under_boxes "switch the default box version" "box use $v"
|
|
# An atomic flip, not unlink+create: ln -sfn leaves a window where current
|
|
# is missing; a rename over it does not.
|
|
ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current"
|
|
converge_bin_links "$ir"
|
|
# Assert the EFFECTIVE result, not the intent: current must resolve to the
|
|
# version asked for, and the chain's own binary must answer that version —
|
|
# a flip that "worked" while the operator's box still runs the old tree is
|
|
# exactly the flakiness this verb exists to end.
|
|
eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
|
|
[ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v"
|
|
expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)"
|
|
if [ -n "$expect" ]; then
|
|
out="$("$ir/current/bin/box" --version 2>&1 || true)"
|
|
case "$out" in
|
|
*"$expect"*) : ;;
|
|
*) die "current/bin/box answers '$out', not version $expect — the symlink chain is broken" ;;
|
|
esac
|
|
fi
|
|
echo "box: switched to $v (current -> versions/$v)"
|
|
}
|
|
|
|
# The uninstall's own confirmation. NOT confirm() above: that one is for box
|
|
# lifecycle verbs and must never auto-accept from the environment (the drill
|
|
# exports BOX_YES=1 for the installer and still expects 'box rm' to refuse
|
|
# without --force). Uninstalling is installer-family, and BOX_YES is the
|
|
# installer-family consent contract — same as install.sh and revoke --purge.
|
|
uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY.
|
|
[ "$force" -eq 1 ] && return 0
|
|
[ -n "${BOX_YES:-}" ] && return 0
|
|
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
|
|
local reply
|
|
printf 'box: %s? [y/N] ' "$1"
|
|
# Same EOF cure as confirm() above — Ctrl-D must abort out loud.
|
|
read -r reply || die "aborted."
|
|
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
|
|
}
|
|
|
|
# 'box uninstall' — the real uninstall #66 left as two rm -rf lines of prose.
|
|
# The full removal runs in the documented order: boxes first (refuse while
|
|
# they exist, or --purge-host tears the stack down with them), then trees and
|
|
# symlinks, and it ENDS by PROVING the absence — like revoke --purge, the
|
|
# last word is a re-check, not a hope.
|
|
cmd_uninstall() {
|
|
local ir a ver="" all=0 purge_host=0 cur p t granted leftover=""
|
|
local targets=()
|
|
for a in ${args[@]+"${args[@]}"}; do
|
|
case "$a" in
|
|
--all) all=1 ;;
|
|
--purge-host) purge_host=1 ;;
|
|
-*) usage_error "unknown option: $a (see 'box help uninstall')" ;;
|
|
*) [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall)"; ver="$a" ;;
|
|
esac
|
|
done
|
|
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)"
|
|
[ -w "$ir" ] || die "cannot write $ir — a global install is uninstalled as root: sudo box uninstall"
|
|
|
|
# -- one version -----------------------------------------------------------
|
|
if [ -n "$ver" ] && [ "$all" -eq 0 ]; then
|
|
[ "$purge_host" -eq 0 ] || usage_error "--purge-host goes with the full uninstall, not a single version"
|
|
valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'box versions')"
|
|
[ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')"
|
|
cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
|
|
# A broken current makes the CURRENT guard below unfireable (cur empty
|
|
# when the link is missing; cur naming a non-directory when it dangles —
|
|
# readlink -f resolves a link whose last component does not exist). Heal
|
|
# first, then decide; never delete around a broken default.
|
|
{ [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \
|
|
|| die "current is dangling — 'box use <version>' to repoint the default first (refusing to remove versions while it is broken)"
|
|
[ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'box use <other>' first, or 'box uninstall --all' for everything"
|
|
uninstall_confirm "remove box version $ver from $ir"
|
|
# rm's exit code is not the verdict — the absence re-check below is (a
|
|
# half-removed tree must be reported as INCOMPLETE, not as a crash).
|
|
rm -rf "$ir/versions/$ver" || true
|
|
if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then
|
|
echo "box: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2
|
|
exit 1
|
|
fi
|
|
echo "box: removed version $ver (the default stays $cur)"
|
|
return 0
|
|
fi
|
|
[ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall) — a version and --all together is ambiguous"
|
|
|
|
# -- everything ------------------------------------------------------------
|
|
if [ "$purge_host" -eq 1 ]; then
|
|
# Granted users' worlds are not ours to erase silently — name them first;
|
|
# 'box revoke <user> --purge' is the clean path (and asserts its absence).
|
|
granted="$(timeout 10 incus project list --format csv 2>/dev/null </dev/null | cut -d, -f1 | grep '^user-' | tr '\n' ' ' || true)"
|
|
[ -n "${granted% }" ] && echo "box: NOTE — granted users still have projects (${granted% }) — 'box revoke <user> --purge' removes each world cleanly first" >&2
|
|
# Consent forwards: --force and BOX_YES are this verb's installer-family
|
|
# yes, and teardown-host must hear it too — otherwise a non-interactive
|
|
# 'uninstall --all --purge-host --force' dies at teardown's own prompt
|
|
# (EOF on read) with the tree untouched but the promise broken.
|
|
if [ "$force" -eq 1 ] || [ -n "${BOX_YES:-}" ]; then
|
|
bash "$root/host/teardown-host.sh" --yes \
|
|
|| die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
|
|
else
|
|
bash "$root/host/teardown-host.sh" \
|
|
|| die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
|
|
fi
|
|
else
|
|
die_under_boxes "uninstall box" "box uninstall (or 'box uninstall --purge-host' to tear the host stack down with them)"
|
|
fi
|
|
uninstall_confirm "remove the ENTIRE box install at $ir (every version)"
|
|
|
|
# The removal set, gathered BEFORE anything is deleted, so the absence
|
|
# assert below re-checks exactly what was promised gone. PATH symlinks are
|
|
# removed only when they resolve into (or dangle at) THIS install root.
|
|
targets+=("$ir")
|
|
while IFS= read -r p; do
|
|
[ -L "$p" ] || continue
|
|
t="$(readlink -f "$p" 2>/dev/null || true)"
|
|
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
|
|
case "$t" in "$ir"/*) targets+=("$p") ;; esac
|
|
done < <(bin_links)
|
|
# Legacy crumbs: the pre-0.4.0 command name and the pre-0.5.0 tree. A real
|
|
# uninstall leaves neither generation behind.
|
|
while IFS= read -r p; do
|
|
p="$(dirname "$p")/claudebox"
|
|
[ -L "$p" ] && targets+=("$p")
|
|
done < <(bin_links)
|
|
[ -d "$HOME/.local/share/claudebox" ] && targets+=("$HOME/.local/share/claudebox")
|
|
mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')
|
|
|
|
# rm's exit code is not the verdict — the absence assert below is (a
|
|
# half-removed tree must be reported as INCOMPLETE by name, not as a crash).
|
|
for p in "${targets[@]}"; do rm -rf "$p" || true; done
|
|
|
|
# END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink.
|
|
# A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims
|
|
# get verified (the revoke --purge discipline).
|
|
for p in "${targets[@]}"; do
|
|
if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi
|
|
done
|
|
if [ -n "$leftover" ]; then
|
|
echo "box: uninstall INCOMPLETE — still present:$leftover" >&2
|
|
echo "box: remove them by hand, and re-check each path is really gone." >&2
|
|
exit 1
|
|
fi
|
|
echo "box: uninstalled — removed:"
|
|
for p in "${targets[@]}"; do echo "box: · $p"; done
|
|
if [ "$purge_host" -eq 0 ]; then
|
|
echo "box: note — the host stack (boxnet, firewall), if this host has one, was NOT touched:"
|
|
echo "box: run teardown-host from a checkout (host/teardown-host.sh), or use --purge-host next time."
|
|
fi
|
|
}
|
|
|
|
cmd_help() { show_help "${args[0]:-}"; }
|
|
|
|
# The escape hatch. The box is resolved and tag-checked; everything else is
|
|
# yours. {} is the instance name; without it, the instance goes last.
|
|
warn_isolation() {
|
|
case " $* " in
|
|
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
|
|
echo "box: warning: this can move the box off the isolation stack" >&2
|
|
echo "box: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
|
|
esac
|
|
}
|
|
|
|
cmd_incus() {
|
|
local rest=("${args[@]:1}") out=() a replaced=0
|
|
[ "${#rest[@]}" -gt 0 ] || usage_error "usage: $(synopsis_of incus)"
|
|
for a in "${rest[@]}"; do
|
|
case "$a" in
|
|
*"{}"*) out+=("${a//\{\}/$inst}"); replaced=1 ;;
|
|
*) out+=("$a") ;;
|
|
esac
|
|
done
|
|
[ "$replaced" -eq 1 ] || out+=("$inst")
|
|
warn_isolation "${out[@]}"
|
|
echo "box: incus ${out[*]}" >&2 # no magic: show what runs
|
|
incus "${out[@]}"
|
|
}
|
|
|
|
# A deliberate, loopback-only door to a box's port — for the one workflow the
|
|
# "no inbound path" contract is too absolute for: you are coding in a box and
|
|
# want to open its dev server in your browser.
|
|
#
|
|
# Two decisions, both load-bearing:
|
|
# · The listen side is ALWAYS 127.0.0.1. The network-facing contract stays
|
|
# true — no other machine can reach the box; only THIS host's loopback gets
|
|
# a door. There is no flag to widen it; that is the escape hatch's job, with
|
|
# its warning.
|
|
# · Each exposure is a named proxy device (expose-<port>), so 'box info' and
|
|
# --list can see it and --remove can undo it. A box with a hole says so.
|
|
#
|
|
# Mechanism (VMs): an Incus 'proxy' device in NAT mode DNATs host
|
|
# 127.0.0.1:<hostport> to the box's <ip>:<port>. The traffic rides the
|
|
# network into the guest, so the in-box server must listen on 0.0.0.0 (not
|
|
# just its own loopback) — inside an isolated box that is safe: boxnet +
|
|
# port-isolation + the ingress drop mean only this door can reach it. Three
|
|
# pieces beside the device itself, each one a drill-found absence:
|
|
# · a SCOPED ACL allow (this box's ip + this port only) — the ingress drop
|
|
# that makes A7 true would eat the DNAT'd packet;
|
|
# · route_localnet + a loopback masquerade on the host (box-firewall.sh) —
|
|
# Incus installs only the DNAT, and a loopback-sourced packet can neither
|
|
# leave the host nor be answered without them;
|
|
# · a static ipv4.address pin on the NIC — NAT mode refuses to start
|
|
# without one (see below).
|
|
exposure_dev() { echo "expose-$1"; } # device name for a port
|
|
|
|
cmd_expose() {
|
|
local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}"
|
|
|
|
# Before ANY incus call: the door's plumbing (the box-isolate ACL, the
|
|
# host firewall's route_localnet + masquerade) is daemon-global state a
|
|
# restricted certificate cannot touch. Without this guard the failure is
|
|
# a lie — box_net_ip cannot read boxnet's (redacted) config, so the
|
|
# restricted user is told their running box "has no boxnet address yet".
|
|
if [ "$(box_tier)" = restricted ]; then
|
|
die "box expose edits the daemon-global ACL and firewall, which the restricted tier cannot modify — ask an incus-admin. (see #74)"
|
|
fi
|
|
|
|
# --list
|
|
if [ "$a2" = "--list" ]; then
|
|
local found=0 d listen connect
|
|
while IFS= read -r d; do
|
|
case "$d" in expose-*) : ;; *) continue ;; esac
|
|
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
|
|
connect="$(incus config device get "$inst" "$d" connect 2>/dev/null)"
|
|
[ "$found" = 0 ] && echo "EXPOSURES for $box"
|
|
found=1
|
|
printf ' %-14s %s → %s\n' "${d#expose-}" "$listen" "$connect"
|
|
done < <(incus config device list "$inst" 2>/dev/null)
|
|
[ "$found" = 0 ] && echo "box: $box has no exposed ports"
|
|
return 0
|
|
fi
|
|
|
|
# --remove <port>
|
|
if [ "$a2" = "--remove" ]; then
|
|
local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove <port>"
|
|
local dev; dev="$(exposure_dev "$port")"
|
|
incus config device get "$inst" "$dev" listen >/dev/null 2>&1 \
|
|
|| die "$box has no exposure on port $port (see 'box expose $box --list')"
|
|
incus config device remove "$inst" "$dev" >/dev/null \
|
|
&& echo "box: closed the door on port $port"
|
|
# Remove the scoped ACL allow, if one was added. Best-effort: its absence
|
|
# is not an error (the drill may show the allow was never needed).
|
|
local ip; ip="$(box_net_ip "$inst" || true)"
|
|
[ -n "$ip" ] && incus network acl rule remove box-isolate ingress \
|
|
action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
|
# If that was the last door, unpin the static address it required. Only
|
|
# then — other exposures still lean on the pin. Best-effort, like the ACL.
|
|
if ! incus config device list "$inst" 2>/dev/null | grep -q '^expose-'; then
|
|
incus config device unset "$inst" eth0 ipv4.address >/dev/null 2>&1
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
# expose <port> [<host-port>]
|
|
local port="$a2" hport="${a3:-$a2}"
|
|
[ -n "$port" ] || usage_error "usage: $(synopsis_of expose)"
|
|
case "$port$hport" in *[!0-9]*) usage_error "ports must be numbers — got port='$port' host-port='$hport'" ;; esac
|
|
|
|
local ip; ip="$(box_net_ip "$inst")" \
|
|
|| die "$box has no boxnet address yet — is it running? (box info $box)"
|
|
|
|
local dev; dev="$(exposure_dev "$port")"
|
|
if incus config device get "$inst" "$dev" listen >/dev/null 2>&1; then
|
|
die "$box already exposes port $port (change or remove it: box expose $box --remove $port)"
|
|
fi
|
|
|
|
# The scoped ACL allow FIRST, so the door is open by the time the proxy uses
|
|
# it. Scoped to this box's IP and this port — it does not widen any other box.
|
|
incus network acl rule add box-isolate ingress action=allow \
|
|
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true
|
|
|
|
# A VM's proxy device must be NAT mode — Incus supports proxy on containers in
|
|
# both modes, but on VMs "NAT mode only" (the userspace forkproxy is a
|
|
# container thing). NAT mode DNATs host:port → instance:port in netfilter, and
|
|
# it needs the host to be the instance's gateway, which boxnet makes true.
|
|
#
|
|
# And NAT mode needs a STATIC address. Incus resolves connect=0.0.0.0 to the
|
|
# NIC's ipv4.address — the device config, not the lease — and refuses when it
|
|
# is unset: `Instance has no static IPv4 address assigned to be used as the
|
|
# connect IP` (the 0.5.0 drill). The first cut pinned an address but the
|
|
# WRONG one (box_ipv4's docker0 decoy); the second cut removed the pin
|
|
# instead of correcting it. Third cut: pin the box's current BOXNET lease.
|
|
# Same address the box already holds, so nothing about its networking moves —
|
|
# the lease just becomes official. Left in place across exposures; unpinned
|
|
# when the last door closes.
|
|
local err; err="$(mktemp)"
|
|
if [ -z "$(incus config device get "$inst" eth0 ipv4.address 2>/dev/null)" ]; then
|
|
# override copies the profile NIC into the instance with the key set; if a
|
|
# local eth0 already exists, override refuses and set is the right verb.
|
|
if ! incus config device override "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err" \
|
|
&& ! incus config device set "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err"; then
|
|
echo "box: could not pin $box's boxnet address ($ip) as static — the NAT proxy requires one:" >&2
|
|
sed 's/^/ /' "$err" >&2; rm -f "$err"
|
|
incus network acl rule remove box-isolate ingress action=allow \
|
|
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
|
die "expose failed"
|
|
fi
|
|
fi
|
|
# connect=0.0.0.0 is deliberate: Incus resolves it to the instance's static
|
|
# IPv4 (the pin above). Naming an address here would work too, but 0.0.0.0
|
|
# cannot repeat the docker0 mistake — there is nothing to get wrong.
|
|
if incus config device add "$inst" "$dev" proxy \
|
|
"listen=tcp:127.0.0.1:$hport" "connect=tcp:0.0.0.0:$port" \
|
|
bind=host nat=true >/dev/null 2>"$err"; then
|
|
rm -f "$err"
|
|
echo "box: 127.0.0.1:$hport → $box:$port"
|
|
echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)"
|
|
# The device alone is not the door: the DNAT'd loopback packet also needs
|
|
# route_localnet on the bridge (box-firewall.sh installs it, with the
|
|
# masquerade). Readable without root — warn instead of handing over a
|
|
# door that silently does not answer.
|
|
if [ "$(cat /proc/sys/net/ipv4/conf/boxnet/route_localnet 2>/dev/null)" != 1 ]; then
|
|
echo "box: WARNING — route_localnet is off on boxnet, so this door will NOT answer." >&2
|
|
echo "box: the host firewall predates expose — apply it: sudo /usr/local/sbin/box-firewall" >&2
|
|
fi
|
|
else
|
|
# NEVER swallow incus's reason — the first cut of this verb died with a bare
|
|
# "could not add the proxy device" and told the drill nothing.
|
|
echo "box: incus refused the proxy device:" >&2
|
|
sed 's/^/ /' "$err" >&2; rm -f "$err"
|
|
incus network acl rule remove box-isolate ingress action=allow \
|
|
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
|
die "expose failed"
|
|
fi
|
|
}
|
|
|
|
# --- dispatch: driven by the table, not by a hand-written case --------------
|
|
|
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
|
IFS='^' read -r _ _ pre _ action ok cnf <<<"$row"
|
|
|
|
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
|
|
case ",$pre," in *,arg2,*) need_arg2 ;; esac
|
|
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
|
|
# The prompt comes from the ROW, never from here. A shared string can only be
|
|
# right for one verb, and it was rm's — which is why 'restore' could not be
|
|
# gated by adding the token alone (#105). A row with 'confirm' and no words is
|
|
# a table bug, and it dies as one rather than asking a blank question.
|
|
case ",$pre," in *,confirm,*)
|
|
[ -n "$cnf" ] || die "internal: '$cmd' is marked confirm but its row carries no prompt"
|
|
confirm "$(fill "$cnf" "$inst")" ;;
|
|
esac
|
|
|
|
case "$action" in
|
|
fn:*)
|
|
"${action#fn:}"
|
|
;;
|
|
incus:*)
|
|
sub="${action#incus:}"
|
|
# word-split intentionally: a subcommand may carry a flag ("delete -f")
|
|
# shellcheck disable=SC2086
|
|
incus $sub "$inst" "${args[@]:1}"
|
|
if [ -n "$ok" ]; then
|
|
echo "box: $(fill "$ok" "${args[0]}")"
|
|
fi
|
|
;;
|
|
esac
|