Merge pull request #82 from dan-claude-bot/feat/template-keys

feat: BOX_REQUIRE_VM / BOX_AUTOSTART template keys + dynamic template test suite
This commit is contained in:
Daniel Marin 2026-07-18 20:08:42 +01:00 committed by GitHub
commit 26a218f29c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 156 additions and 19 deletions

View file

@ -7,6 +7,22 @@ which records not just what changed but what each drill run proved.
### Added
- **Server-posture template keys** (#81, carved from #69) — two optional
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
(exit 2): such a template's trust boundary is the VM. `BOX_AUTOSTART=1`
stamps `boot.autostart=true` at launch, per-instance like `limits.*`, 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, on
purpose.
- **Dynamic template test suite** (#81, carved from #69) — `test/cli.sh`
discovers `templates/*/` instead of hardcoding the list, so a new template
cannot ship unseen. Per template: `box.env` is driven through the real,
extracted `load_template` (unknown keys and missing `BOX_IMAGE`/`BOX_USER`
fail, fixtures proving both dies); `user-data.yaml` exists, declares
`#cloud-config`, parses as YAML, and installs tmux (#65). Grep guards pin
the `cmd_new` half: the `REQUIRE_VM` refusal orders after `pick_mode`, and
`boot.autostart` is stamped only under the `T_AUTOSTART` guard.
- **`box export` / `box import`** (#70) — a box's state that survives the box
_and_ the host, unblocking #66's humane upgrade flow (down, export, rm,
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into

56
bin/box
View file

@ -235,8 +235,11 @@ 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 (independently: BOX_REQUIRE_VM insists on
VM mode, BOX_AUTOSTART survives host reboots) —
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).
@ -246,6 +249,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
@ -261,11 +266,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
@ -824,18 +829,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
@ -857,7 +867,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
@ -907,6 +919,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
@ -916,6 +939,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

@ -130,15 +130,108 @@ 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
# The green path the two new keys exist for: no in-tree template sets them yet
# (the seed lands after rig#31), so without this fixture the case arms could be
# deleted and the suite would stay green while the keys silently died as
# "unknown key" at first use. Accepted AND surfaced, through the real parser.
mkdir -p "$EVILROOT/templates/server"
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \
> "$EVILROOT/templates/server/box.env"
check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \
0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$EVILROOT" server
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
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" ]'
# Order is necessary, not sufficient: a regression to the RAW flag
# ([ "$mode" != vm ]) would still sit after pick_mode — and would refuse every
# auto mint on a valid VM host. Pin the guard to the EFFECTIVE operand: the
# T_REQUIRE_VM line itself must compare $m, the pick_mode result.
# shellcheck disable=SC2016 # the $-strings are literals in the target file
check "new: the REQUIRE_VM guard compares the effective mode (\$m)" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
| grep "T_REQUIRE_VM" | grep -qF "\"\$m\" != vm"'
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