box/docs/plans/2026-07-17-staging-template.md
claude-hdb 81763833b0 docs: plan for the staging template
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:49:40 +00:00

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 shellsudo 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-isolate ACL, box-firewall, or expose — 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: a box.env (parsed against the allowlist, never sourced) and a user-data.yaml (passed to Incus verbatim). Templates still cannot touch the network or a security.* 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_VM refuses 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=true is stamped only when BOX_AUTOSTART is set — absent on every other template's boxes. Clones inherit it via incus copy, which keeps all non-volatile config keys (verified in Incus's InstanceIncludeWhenCopying; same mechanism the user.* 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(): initialize T_REQUIRE_VM=""; T_AUTOSTART="" and accept BOX_REQUIRE_VM / BOX_AUTOSTART in 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 the new/templates help prose) — there is still no key for a network or a security.* flag, on purpose.
  • cmd_new (fresh-mint path): after m="$(pick_mode)", if the template set BOX_REQUIRE_VM and the effective mode is not vm: usage_error when --container was 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 set BOX_AUTOSTART, append --config boot.autostart=true to the launch arguments — the same per-instance mechanism as limits.cpu. The --from clone path needs nothing: incus copy carries 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 with rig bootstrap workload inside, 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: user ops (NOPASSWD sudo, lock_passwd: true), curl + ca-certificates, docker via get.docker.com (the mechanism the claude template uses), and rig preinstalled via its installer (curl -fsSL …/rig/main/install.sh | bash, with HOME=/root pinned — cloud-init's runcmd does not guarantee HOME, and rig's installer derives its install dir from it: as root it lands in /root/.local/share/rig with a /usr/local/bin/rig symlink, 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 staging template 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 bootstrap snapshot, 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.yaml parse as YAML (pyyaml).
  • Grep assertions: BOX_REQUIRE_VM/BOX_AUTOSTART appear in the allowlist; boot.autostart is stamped only under the BOX_AUTOSTART guard; no template other than staging sets either key.