feat: server-class staging template — box mints, rig converges, cast registers #69
7 changed files with 410 additions and 19 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -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
55
bin/box
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
166
docs/plans/2026-07-17-staging-template.md
Normal file
166
docs/plans/2026-07-17-staging-template.md
Normal 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
18
templates/staging/box.env
Normal 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"
|
||||
31
templates/staging/user-data.yaml
Normal file
31
templates/staging/user-data.yaml
Normal 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'
|
||||
105
test/cli.sh
105
test/cli.sh
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue