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:
commit
26a218f29c
3 changed files with 156 additions and 19 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -7,6 +7,22 @@ which records not just what changed but what each drill run proved.
|
||||||
|
|
||||||
### Added
|
### 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
|
- **`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,
|
_and_ the host, unblocking #66's humane upgrade flow (down, export, rm,
|
||||||
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into
|
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into
|
||||||
|
|
|
||||||
56
bin/box
56
bin/box
|
|
@ -235,8 +235,11 @@ being told.
|
||||||
|
|
||||||
--name <box> Required. The box's name.
|
--name <box> Required. The box's name.
|
||||||
--template <t> Template to mint from; 'box templates' lists them.
|
--template <t> Template to mint from; 'box templates' lists them.
|
||||||
A template sets image, user and resources — never
|
A template sets image, user, resources and boot
|
||||||
the network: every template gets the same isolation.
|
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>.
|
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
|
||||||
--cpu <n> CPUs for this mint (limits.cpu, verbatim to Incus).
|
--cpu <n> CPUs for this mint (limits.cpu, verbatim to Incus).
|
||||||
--memory <size> RAM for this mint, e.g. 3GiB (limits.memory).
|
--memory <size> RAM for this mint, e.g. 3GiB (limits.memory).
|
||||||
|
|
@ -246,6 +249,8 @@ being told.
|
||||||
default wherever /dev/kvm exists; container mode
|
default wherever /dev/kvm exists; container mode
|
||||||
(security.nesting=true) is the fallback for hosts
|
(security.nesting=true) is the fallback for hosts
|
||||||
without nested virt — weaker isolation, dev/test only.
|
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 /
|
Resources resolve most-specific-first: these flags, then BOX_CPU /
|
||||||
BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
|
BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
|
||||||
|
|
@ -261,11 +266,11 @@ EOF
|
||||||
;;
|
;;
|
||||||
templates) cat <<'EOF'
|
templates) cat <<'EOF'
|
||||||
List the templates this install can mint, with their descriptions. A template
|
List the templates this install can mint, with their descriptions. A template
|
||||||
is a directory under templates/: a box.env (image, user, resources — parsed
|
is a directory under templates/: a box.env (image, user, resources, boot
|
||||||
against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed
|
demands — parsed against an allowlist, never sourced) and a user-data.yaml
|
||||||
to Incus verbatim). Templates cannot touch the network or security flags —
|
(cloud-init, passed to Incus verbatim). Templates cannot touch the network
|
||||||
the shared box-net profile is the placement contract, so every template gets
|
or security flags — the shared box-net profile is the placement contract, so
|
||||||
the same isolation.
|
every template gets the same isolation.
|
||||||
|
|
||||||
box templates
|
box templates
|
||||||
box new --name scratch --template blank
|
box new --name scratch --template blank
|
||||||
|
|
@ -824,18 +829,23 @@ reset_identity() {
|
||||||
wait_agent "$i"
|
wait_agent "$i"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Templates set image, user, resources and cloud-init — NOTHING else. The
|
# Templates set image, user, resources, boot demands and cloud-init — NOTHING
|
||||||
# box.env file is parsed against this allowlist, never sourced: sourcing would
|
# else. The box.env file is parsed against this allowlist, never sourced:
|
||||||
# hand every template arbitrary bash execution on the HOST at mint time. And
|
# sourcing would hand every template arbitrary bash execution on the HOST at
|
||||||
# there is deliberately no key for a network or a security flag — the shared
|
# mint time. And there is deliberately no key for a network or a security flag
|
||||||
# box-net profile is the placement contract, so no template can weaken
|
# — the shared box-net profile is the placement contract, so no template can
|
||||||
# isolation. 'blank' is a box with nobody home, not a box with the safety off.
|
# 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() {
|
load_template() {
|
||||||
local t="$1" dir line key val
|
local t="$1" dir line key val
|
||||||
dir="$root/templates/$t"
|
dir="$root/templates/$t"
|
||||||
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
|
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
|
||||||
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
|
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
|
||||||
T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
|
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
|
while IFS= read -r line || [ -n "$line" ]; do
|
||||||
case "$line" in ''|\#*) continue ;; esac
|
case "$line" in ''|\#*) continue ;; esac
|
||||||
case "$line" in
|
case "$line" in
|
||||||
|
|
@ -857,7 +867,9 @@ load_template() {
|
||||||
BOX_CPU) T_CPU="$val" ;;
|
BOX_CPU) T_CPU="$val" ;;
|
||||||
BOX_MEMORY) T_MEMORY="$val" ;;
|
BOX_MEMORY) T_MEMORY="$val" ;;
|
||||||
BOX_DISK) T_DISK="$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
|
esac
|
||||||
done <"$dir/box.env"
|
done <"$dir/box.env"
|
||||||
# Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
|
# 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=()
|
local t="${template:-blank}" m extra=()
|
||||||
load_template "$t"
|
load_template "$t"
|
||||||
m="$(pick_mode)"
|
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
|
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
|
||||||
# security.secureboot=false: Incus defaults VMs to secureboot ON, and a
|
# 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
|
# 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
|
# turning it off boots reliably across image rebuilds. Container mode has
|
||||||
# no firmware, so it does not apply there.
|
# 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
|
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
|
# Root size is a VM launch concern; a container's root rides the pool. Say
|
||||||
# so instead of silently dropping an explicit --disk.
|
# 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
|
[ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2
|
||||||
|
|
|
||||||
103
test/cli.sh
103
test/cli.sh
|
|
@ -130,15 +130,108 @@ check "install.sh: still no-ops on an existing install (#66)" 0 "" \
|
||||||
grep -qF 'already installed' "$ROOT/install.sh"
|
grep -qF 'already installed' "$ROOT/install.sh"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Templates — #65 tmux. `box tmux` runs `tmux new-session` INSIDE the box, so a
|
# Templates — DYNAMIC over templates/*/ (#68): the loop discovers every
|
||||||
# template that never installs tmux fails with "tmux: command not found". Every
|
# template directory, so a new template cannot ship without passing these (the
|
||||||
# template must carry it in its cloud-init package list.
|
# 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 "" \
|
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
|
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
|
# 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
|
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue