6.9 KiB
staging template Implementation Plan
Spec: issue #68. Companion: heavy-duty/rig#22 (
rig bootstrap workload, implemented separately in the rig repo). Steps use checkbox (- [ ]) syntax for tracking.
Goal: A server-class staging template so staging VMs (registered in the
control plane as servers, reachable tailnet-only) are minted by box instead of
by a hand-rolled incus launch in a private infra repo. The template ships
docker + rig preinstalled and nothing else; the operator runs
box shell → sudo rig bootstrap workload inside, and rig — not box — hardens
sshd, joins the tailnet, and holds the auth key in process memory.
Why box's isolation stack needs zero changes: the guest joins the tailnet
itself, outbound. The host-side stack (boxnet ACL, port isolation,
box-firewall, loopback-only expose) only ever sees allowed outbound UDP.
Isolation becomes the feature: the staging box still cannot reach the LAN, the
host, or a sibling box — it is reachable only over the tailnet, which is
exactly the staging posture.
Architecture: one new data-only template directory (templates/staging/),
two new keys in the box.env allowlist (BOX_REQUIRE_VM, BOX_AUTOSTART)
honored by cmd_new in bin/box, and a design-doc section. No new commands,
no new flags, no dependency changes.
Non-goals
- No changes to boxnet, the
box-isolateACL,box-firewall, orexpose— the whole point is that none are needed. - No credential injection — no
TS_AUTHKEY, no ssh keys, nothing. The creds-free contract is untouched; rig owns the join, inside the guest. - No tailscale and no openssh-server in the template — rig installs both at bootstrap time, so box ships neither.
- No control-plane/Coolify awareness (that is cast's job, out of scope).
- No agent tooling in the box: no
~/.claude, no agent-context file — this is a server, not an agent devbox.
Global constraints
templates/staging/is data-only: abox.env(parsed against the allowlist, never sourced) and auser-data.yaml(passed to Incus verbatim). Templates still cannot touch the network or asecurity.*key.- The two new keys are optional and absent from every other template; existing templates mint exactly as before, and the allowlist still rejects unknown keys.
BOX_REQUIRE_VMrefuses both the silent container fallback (no/dev/kvm→ exit 1, it went wrong) and an explicit--container(exit 2, you asked wrong), per the existing exit-code contract.boot.autostart=trueis stamped only whenBOX_AUTOSTARTis set — absent on every other template's boxes. Clones inherit it viaincus copy, which keeps all non-volatile config keys (verified in Incus'sInstanceIncludeWhenCopying; same mechanism theuser.*stamps already rely on — audit B2), so no clone-path code is needed.- Match the repo's disciplined-bash style; minimal diff; conventional
commits (
type(scope): subject).
Task 1: BOX_REQUIRE_VM + BOX_AUTOSTART in bin/box
Files: modify bin/box only.
load_template(): initializeT_REQUIRE_VM=""; T_AUTOSTART=""and acceptBOX_REQUIRE_VM/BOX_AUTOSTARTin the key allowlist. Update the "image, user and resources, nothing else" phrasing where it enumerates the allowlist (the function's header comment, the unknown-key error, and thenew/templateshelp prose) — there is still no key for a network or asecurity.*flag, on purpose.cmd_new(fresh-mint path): afterm="$(pick_mode)", if the template setBOX_REQUIRE_VMand the effective mode is notvm:usage_errorwhen--containerwas asked for explicitly,die(naming/dev/kvm) when the host fell back. No silent container fallback for a server-class template.cmd_new(fresh-mint path): when the template setBOX_AUTOSTART, append--config boot.autostart=trueto the launch arguments — the same per-instance mechanism aslimits.cpu. The--fromclone path needs nothing:incus copycarries the key (see Global constraints).- Commit:
feat(new): BOX_REQUIRE_VM and BOX_AUTOSTART template keys
Task 2: the staging template
Files: create templates/staging/box.env,
templates/staging/user-data.yaml.
box.env, following the existing templates' format and header-comment voice:BOX_DESCRIPTION(server-class staging VM: docker + rig preinstalled; converge withrig bootstrap workloadinside, then register in the control plane),BOX_IMAGE="images:debian/13/cloud",BOX_USER="ops",BOX_CPU="4",BOX_MEMORY="8GiB",BOX_DISK="100GiB"(build-sized: the control plane builds on the target),BOX_REQUIRE_VM="1",BOX_AUTOSTART="1".user-data.yaml, modeled on the claude template but server-minimal: userops(NOPASSWD sudo,lock_passwd: true),curl+ca-certificates, docker viaget.docker.com(the mechanism the claude template uses), and rig preinstalled via its installer (curl -fsSL …/rig/main/install.sh | bash, withHOME=/rootpinned — cloud-init's runcmd does not guaranteeHOME, and rig's installer derives its install dir from it: as root it lands in/root/.local/share/rigwith a/usr/local/bin/rigsymlink, which is what we want since rig runs as root). A comment states that tailscale, openssh-server and every credential are deliberately absent — rig installs those at bootstrap time. No~/.claude, no agent-context file.- Commit:
feat(templates): staging — server-class VM, docker + rig, creds-free
Task 3: design-doc amendment
Files: modify docs/box-design.md.
- New section (after Isolation, matching the doc's voice): the isolation
guarantee is "no inbound via the host's network position"; a guest can
deliberately join an overlay network (tailnet) from inside and invite
management in over its own outbound tunnel; the
stagingtemplate is the sanctioned server-class use of that, layered as box mints / rig converges (inside the guest) / cast registers; and the snapshot-before-join rule — clone from a pre-rig bootstrapsnapshot, because a post-join clone duplicates the source's tailnet identity. - Commit:
docs(design): server-class boxes — overlay joins and snapshot-before-join
Test plan
No Incus in the implementation environment, so runtime minting is not
exercised here — the acceptance criteria in #68 (box new --template staging
mints a VM on a KVM host, refuses on a non-KVM host, rig bootstrap workload
succeeds inside) are exercised on a real host. Static gate, all of which must
pass before merge:
bash -n bin/box— parses.shellcheck bin/box— no new findings against main.- Both
templates/staging/*.yaml/ all templates'user-data.yamlparse as YAML (pyyaml). - Grep assertions:
BOX_REQUIRE_VM/BOX_AUTOSTARTappear in the allowlist;boot.autostartis stamped only under theBOX_AUTOSTARTguard; no template other thanstagingsets either key.