feat: server-class staging template — box mints, rig converges, cast registers #69

Closed
dan-claude-bot wants to merge 6 commits from feat/staging-template into main
7 changed files with 410 additions and 19 deletions

View file

@ -5,6 +5,27 @@ which records not just what changed but what each drill run proved.
## Unreleased
### Added
- **Server-class boxes: the `staging` template** (#68) — Debian 13 VM with
docker + rig (+ tmux, the #65 contract) preinstalled and *nothing* that
joins or admits: tailscale, sshd and every credential are rig's to install
at bootstrap time (`box shell` → `sudo rig bootstrap workload`), inside
the guest — box stays creds-free. Two new optional `box.env` keys carry
the posture: `BOX_REQUIRE_VM=1` (no container fallback, and `--container`
is refused — the VM is the trust boundary and the guest runs docker) and
`BOX_AUTOSTART=1` (`boot.autostart=true`, so the box returns from a host
reboot without an operator; clones inherit it via `incus copy`). Still no
key for a network or a `security.*` flag. The design doc gains the
overlay-join clarification and the snapshot-before-join rule.
- **Template test suite**`test/cli.sh`'s template coverage is dynamic
over `templates/*/`, so a new template cannot ship unseen: `box.env` is
driven through the real, extracted `load_template` (unknown keys and
missing `BOX_IMAGE`/`BOX_USER` fail), `user-data.yaml` must exist, declare
`#cloud-config`, parse as YAML and install tmux; staging additionally
proves its boot demands, docker + rig, and the creds-free refusal (no
tailscale/authkey/ssh in effective cloud-init lines).
## 0.6.0 — 2026-07-18
### Added

55
bin/box
View file

@ -224,8 +224,10 @@ being told.
--name <box> Required. The box's name.
--template <t> Template to mint from; 'box templates' lists them.
A template sets image, user and resources — never
the network: every template gets the same isolation.
A template sets image, user, resources and boot
demands (a server-class one insists on VM mode and
autostart) — 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).
@ -235,6 +237,8 @@ being told.
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
@ -250,11 +254,11 @@ 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 — parsed
against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed
to Incus verbatim). Templates cannot touch the network or security flags —
the shared box-net profile is the placement contract, so every template gets
the same isolation.
is a directory under templates/: a box.env (image, user, resources, boot
demands — parsed against an allowlist, never sourced) and a user-data.yaml
(cloud-init, passed to Incus verbatim). Templates cannot touch the network
or security flags — the shared box-net profile is the placement contract, so
every template gets the same isolation.
box templates
box new --name scratch --template blank
@ -688,18 +692,23 @@ reset_identity() {
wait_agent "$i"
}
# Templates set image, user, resources 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.
# Templates set image, user, resources, boot demands 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.
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=""
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|\#*) continue ;; esac
case "$line" in
@ -721,7 +730,9 @@ load_template() {
BOX_CPU) T_CPU="$val" ;;
BOX_MEMORY) T_MEMORY="$val" ;;
BOX_DISK) T_DISK="$val" ;;
*) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;;
BOX_REQUIRE_VM) T_REQUIRE_VM="$val" ;;
BOX_AUTOSTART) T_AUTOSTART="$val" ;;
*) die "template '$t': unknown key '$key' — a template sets image, user, resources and boot demands, 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
@ -785,6 +796,17 @@ cmd_new() {
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
@ -794,6 +816,11 @@ cmd_new() {
# 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

View file

@ -188,6 +188,39 @@ certificate) and asserts the absence afterwards. A failed `grant` backs its
own group-add out on exit for the same reason: no half-granted user holding
an un-narrowed socket.
## Server-class boxes: joining an overlay from inside
"No inbound path" is a claim about the **host's network position**: box builds
no route in — no SSH, no forwarded port, no LAN listener beyond `expose`'s
loopback door. It was never a claim about what a guest may do with its own
outbound reach. A guest can deliberately join an overlay network (a tailnet)
from inside and invite management in over the tunnel it established — the
host-side stack only ever sees allowed outbound UDP, and every rule above
still holds: the box still cannot reach the host, the LAN, or a sibling box.
It is reachable *only* over the overlay. For a staging server that is not a
hole in the posture, it **is** the posture: tailnet-only, never LAN or public.
The `staging` template is the sanctioned server-class use of that, and the
layering keeps each tool honest:
- **box** mints the VM — docker + rig preinstalled, still strictly creds-free.
- **rig**, run *inside* the box (`box shell` → `sudo rig bootstrap workload`),
hardens sshd and joins the tailnet. rig holds the auth key in process
memory per its own contract; box never sees it.
- **cast** registers the box in the control plane by its tailnet address.
Two template keys carry the server posture, and only those: `BOX_REQUIRE_VM`
(no container fallback — the VM is the trust boundary, and the guest runs
docker) and `BOX_AUTOSTART` (`boot.autostart`, so the box returns from a host
reboot without an operator). There is still no key for a network or a
`security.*` flag.
**Snapshot before the join.** Clone from a snapshot taken *before*
`rig bootstrap` runs: a post-join clone carries its source's tailnet identity,
and two machines answering as one node is the overlay's version of the DHCP
collision `reset_identity` exists for. Mint → snapshot → bootstrap, in that
order.
## Non-goals
- Interactive-first: install and setup prompt by default (`BOX_YES=1` and the

View file

@ -0,0 +1,166 @@
# `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-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.
---
## Addendum (2026-07-18): rebased onto main; the template test suite
The branch was rebased onto main, which had since gained the restricted tier
(#74), a CI workflow, and `test/cli.sh`. What that changed here:
- **`load_template` conflicts** — main replaced the `[ -n … ] && [ -n … ] ||
die` required-keys idiom with the spelled-out `if [ -z … ]` form (SC2015)
and grew the SC2034 directive block; the two new key arms were re-applied
onto that version, both intact.
- **`cmd_new`** — main added a tier-aware box-net pre-flight at the top of
the function; the `BOX_REQUIRE_VM` refusal stays in the fresh-mint branch,
after `pick_mode` (it must read the *effective* mode). Its message holds
for both tiers: `/dev/kvm` is a host fact, and admin and restricted mints
go through the same daemon, so the fix is the same — a KVM host, not a
grant.
- **tmux**`box tmux` is a contract every template honors (#65, asserted
by `test/cli.sh`), so the staging package list carries tmux; the operator
babysits `rig bootstrap workload` through it.
- **The template test suite** (maintainer request): `test/cli.sh`'s template
coverage is now *dynamic* over `templates/*/` — a new template cannot ship
unseen. Per template: `box.env` driven through the real, extracted
`load_template` (unknown keys and missing `BOX_IMAGE`/`BOX_USER` fail);
`user-data.yaml` exists, declares `#cloud-config`, parses as YAML
(python3+pyyaml, loudly skipped where absent), installs tmux.
Staging-specific: both boot demands proven through the parser, docker +
rig present, and a creds-free grep-refusal (no tailscale/authkey/ssh in
effective cloud-init lines). Grep guards pin the `cmd_new` half: the
refusal orders after `pick_mode`; `boot.autostart` is stamped only under
the `T_AUTOSTART` guard.

18
templates/staging/box.env Normal file
View file

@ -0,0 +1,18 @@
# The staging template — a server-class box: docker + rig, no agent, no creds.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates.
#
# BOX_REQUIRE_VM: no container fallback — the VM is the staging trust
# boundary, and the guest runs docker. BOX_AUTOSTART: a staging server must
# come back on its own after a host reboot. Resources are build-sized: the
# control plane builds on the target.
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"
BOX_REQUIRE_VM="1"
BOX_AUTOSTART="1"

View file

@ -0,0 +1,31 @@
#cloud-config
# The staging template — a server, not an agent devbox: no agent, no
# agent-context file, and (as everywhere in box) no credentials. Docker and
# rig are preinstalled and that is all. Tailscale, openssh-server and every
# credential are deliberately ABSENT: rig installs and hardens sshd and joins
# the tailnet at bootstrap time ('box shell' → 'sudo rig bootstrap workload'),
# holding the auth key in process memory — box never sees it.
# BOX_USER in box.env must match the user created here — the file is passed
# to Incus verbatim.
users:
- name: ops
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
package_update: true
packages:
- curl
- ca-certificates
# tmux is the one agent-adjacent tool a server box still carries: 'box tmux'
# runs 'tmux new-session' INSIDE the box on EVERY template (#65) — the
# operator babysits 'rig bootstrap workload' through it — and test/cli.sh
# asserts it for every template directory, this one included.
- tmux
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker ops
# rig runs as root, so install it as root: its installer lands the tree in
# $HOME/.local/share/rig and symlinks /usr/local/bin/rig. HOME is pinned
# because cloud-init's runcmd does not guarantee one, and the installer
# derives its install dir from it.
- HOME=/root bash -c 'curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash'

View file

@ -130,15 +130,110 @@ check "install.sh: still no-ops on an existing install (#66)" 0 "" \
grep -qF 'already installed' "$ROOT/install.sh"
# ---------------------------------------------------------------------------
# Templates — #65 tmux. `box tmux` runs `tmux new-session` INSIDE the box, so a
# template that never installs tmux fails with "tmux: command not found". Every
# template must carry it in its cloud-init package list.
# Templates — DYNAMIC over templates/*/ (#68): the loop discovers every
# template directory, so a new template cannot ship without passing these (the
# old hardcoded blank/claude/codex/grok list let exactly that happen). The
# box.env parse is proven against the REAL allowlist: load_template is
# extracted from bin/box and DRIVEN against each template — the same
# source-the-pure-function trick install.sh's DEST block and box_tier get
# below — so an unknown key, a missing BOX_IMAGE/BOX_USER, or a line that is
# not KEY="value" fails HERE, not at mint time on a host.
# ---------------------------------------------------------------------------
for t in blank claude codex grok; do
TPLFN="$(mktemp)"
awk '/^load_template\(\) \{/,/^\}/' "$ROOT/bin/box" > "$TPLFN"
check "load_template: extracted from bin/box (guards the awk)" 0 "unknown key" cat "$TPLFN"
check "load_template: the extracted function is valid bash" 0 "" bash -n "$TPLFN"
# tpl <root> <template> — run the real parser against <root>/templates/, print
# what it resolved. $0 carries the extracted-function file into the subshell.
tpl() {
root="$1" bash -c '
die() { echo "box: $*" >&2; exit 1; }
. "$0"; load_template "$1"
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s\n" \
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART"
' "$TPLFN" "$2"
}
# The allowlist itself is load-bearing: a template must not be able to grow a
# network key, and the required keys must still be required. Fixture-driven,
# against a throwaway root — exactly the dies a green parse cannot prove.
EVILROOT="$(mktemp -d)"; mkdir -p "$EVILROOT/templates/evil"
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="dev"\nBOX_NETWORK="lan"\n' \
> "$EVILROOT/templates/evil/box.env"
check "load_template: an unknown key dies (no template grows a network)" 1 "unknown key" \
tpl "$EVILROOT" evil
printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env"
check "load_template: a missing BOX_IMAGE dies" 1 "required" tpl "$EVILROOT" evil
rm -rf "$EVILROOT"
# YAML well-formedness needs python3 + pyyaml; the CI runner has both. Skip
# gracefully (never silently) where they are missing.
HAVE_YAML=0
command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null && HAVE_YAML=1
for d in "$ROOT"/templates/*/; do
t="$(basename "$d")"
# The parse itself asserts the allowlist AND the required keys (the driven
# function dies without BOX_IMAGE/BOX_USER); the greps pin both keys to the
# FILE, so neither can quietly become an inherited default.
check "template '$t': box.env parses against the real allowlist" 0 "USER=" tpl "$ROOT" "$t"
check "template '$t': box.env sets BOX_IMAGE" 0 "" grep -q '^BOX_IMAGE=' "$d/box.env"
check "template '$t': box.env sets BOX_USER" 0 "" grep -q '^BOX_USER=' "$d/box.env"
# cloud-init is passed to Incus verbatim, so it must exist, declare itself,
# and be well-formed — a mint is far too late to learn about a typo.
check "template '$t': user-data.yaml exists" 0 "" test -f "$d/user-data.yaml"
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
check "template '$t': user-data.yaml begins with #cloud-config" 0 "" \
bash -c 'head -1 "$1" | grep -qx "#cloud-config"' _ "$d/user-data.yaml"
if [ "$HAVE_YAML" = 1 ]; then
check "template '$t': user-data.yaml is well-formed YAML" 0 "" \
python3 -c 'import sys, yaml; yaml.safe_load(open(sys.argv[1]))' "$d/user-data.yaml"
else
echo "skip: template '$t' YAML well-formedness (no python3+pyyaml here; CI has both)"
fi
# #65: 'box tmux' runs 'tmux new-session' INSIDE the box, so every
# template's package list must carry tmux or the verb dies inside.
check "template '$t': installs tmux (#65)" 0 "" \
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$ROOT/templates/$t/user-data.yaml"
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$d/user-data.yaml"
done
# ---------------------------------------------------------------------------
# The staging template (#68) — server-class, still creds-free. Its posture in
# full: both boot demands set (proven through the real parser, not a grep),
# docker + rig preinstalled, and NOTHING that joins or admits — tailscale,
# auth keys and ssh in any spelling are rig's to install at bootstrap time,
# inside the guest, so their absence from the shipped cloud-init IS the
# creds-free contract. Comments may name them (to say why they are absent);
# effective lines must not.
# ---------------------------------------------------------------------------
STG="$ROOT/templates/staging"
check "staging: demands VM mode and autostart (via the real parser)" \
0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$ROOT" staging
check "staging: installs docker (get.docker.com)" 0 "" \
grep -qF 'get.docker.com' "$STG/user-data.yaml"
check "staging: preinstalls rig" 0 "" \
grep -qF 'rig/main/install.sh' "$STG/user-data.yaml"
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
check "staging: no tailscale/authkey/ssh outside comments (creds-free)" 1 "" \
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "tailscale|authkey|ssh"' _ "$STG/user-data.yaml"
rm -f "$TPLFN"
# The keys' cmd_new half, grepped the way the expose guard is (line order —
# a daemon-free run cannot mint). The REQUIRE_VM refusal must read the
# EFFECTIVE mode, i.e. come after pick_mode: refusing on the template key
# alone would refuse valid VM mints, and a guard deleted in a refactor must
# not ship green.
# shellcheck disable=SC2016 # the $-strings are literals in the target file
check "new: the REQUIRE_VM refusal orders after pick_mode" 0 "" bash -c '
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
pick="$(printf "%s\n" "$fn" | grep -n "pick_mode" | head -1 | cut -d: -f1)"
guard="$(printf "%s\n" "$fn" | grep -n "T_REQUIRE_VM" | head -1 | cut -d: -f1)"
[ -n "$pick" ] && [ -n "$guard" ] && [ "$pick" -lt "$guard" ]'
check "new: boot.autostart is stamped under the T_AUTOSTART guard" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
| grep -F "boot.autostart=true" | grep -q "T_AUTOSTART"'
# ---------------------------------------------------------------------------
# The restricted tier (#74). box_tier() is the decision the whole tier hangs
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run