test+ci: add CI workflow and a dependency-free test suite

box had no CI and no unit tests — only the live-host drill. Mirror rig's CI:
one `check` job = globstar `shellcheck -x` over bin/* and **/*.sh, then
`bash test/cli.sh`. The suite is dependency-free and runs non-root with no
Incus: the full CLI contract; install.sh's DEST/BINDIR branch driven
functionally against a shim `id` (both tiers + the BOX_HOME/BOX_BIN overrides);
the root-only a+rX and #66's confirm/no-op flow grep-guarded; tmux asserted in
every template. Pre-existing repo shellcheck findings (bin/box SC2034/SC2015/
SC2020, and file-level SC2015 idioms in doctor.sh/wipe.sh/migrate-host.sh) were
resolved — real fixes where behaviour allows, reasoned disables otherwise — so
the new CI is green over the whole repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 00:01:15 +00:00
parent 9849021b6a
commit 437a3a8e35
6 changed files with 209 additions and 5 deletions

32
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: ci
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: shellcheck
# -x follows `source`/`.` directives; box has no lib split today, but the
# flag costs nothing and keeps the invocation identical to rig's.
# globstar so a script in a new subdirectory is linted without anyone
# remembering to edit this list; bin/* covers the extensionless entrypoint
# (bin/box). The file list is printed so under-coverage shows up in the log.
run: |
shopt -s globstar
files=(bin/* **/*.sh)
printf 'shellcheck: %s\n' "${files[@]}"
shellcheck -x "${files[@]}"
- name: cli tests
run: bash test/cli.sh
# NOT run here: the drill (drill/drill.sh) and the multi-user rehearsal
# (drill/multiuser.sh). Both stand up a real Incus, rearrange the host's
# network/firewall, and — for the rehearsal — create throwaway system users.
# None of that belongs in CI: it needs a disposable host and root, and it is
# the answer to #72 Task 0's substrate question, which a container runner
# cannot answer. CI stays static + dependency-free (shellcheck + cli.sh),
# exactly as this repo's design demands ("isolation claims are tested on a
# real host, never reasoned about" — docs/box-design.md).

16
bin/box
View file

@ -641,6 +641,13 @@ load_template() {
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
esac
val="${val#\"}"; val="${val%\"}"
# T_DESC is parsed for symmetry with the other BOX_* keys, but cmd_templates
# re-reads BOX_DESCRIPTION straight from the file (a box is listed without
# ever loading its template), so the parsed value here is never read. Keep the
# row — deleting it would turn box.env's own key into an "unknown key" error at
# mint time. (SC2034 disabled for the branch below; the directive must sit on
# the whole case, not an individual arm.)
# shellcheck disable=SC2034
case "$key" in
BOX_DESCRIPTION) T_DESC="$val" ;;
BOX_IMAGE) T_IMAGE="$val" ;;
@ -651,7 +658,12 @@ load_template() {
*) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;;
esac
done <"$dir/box.env"
[ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required"
# Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
# dies (which is what we want) — but it reads as an if-then-else it is not, so
# spell the guard out (SC2015).
if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then
die "template '$t': BOX_IMAGE and BOX_USER are required"
fi
# Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
# > BOX_* environment (how a small host or the drill shrinks every box it
# mints) > the template's file > defaults. Values pass to Incus verbatim —
@ -793,7 +805,7 @@ box_net_ip() {
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
[ -n "$pfx" ] || return 1
incus list "$1" --format csv --columns 4 2>/dev/null \
| tr -d '"' | tr ' ,' '\n\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
| tr -d '"' | tr ' ,' '\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
}
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.

View file

@ -14,6 +14,11 @@
#
# This script is the answer to "what state is the host actually in?" — the
# question that kept getting answered by hand.
#
# ok/no/inf/head_ all return 0, so the 'A && ok "…" || no "…"' idiom this file
# is built on cannot hit the C-may-run-when-A-is-true trap SC2015 warns about
# (same reasoning as drill.sh's ok/no).
# shellcheck disable=SC2015
set -u
FIX=0; PIN=0

View file

@ -19,6 +19,11 @@
# NOT 'set -e': on a wipe, a step that finds nothing to remove is success,
# not failure. Every removal states what it did; silence is never trusted
# (the exit-code lesson, again).
#
# The file is one long 'removal && say "did X" || say "X failed"'. say always
# returns 0, so the C-may-run-when-A-is-true trap SC2015 warns about cannot fire
# here (same reasoning as drill.sh's ok/no).
# shellcheck disable=SC2015
set -u
YES=0; PURGE_STORAGE=0

View file

@ -19,9 +19,13 @@
#
# NOT 'set -e' around the per-box work: a box that fails one step is reported
# and skipped, not a crash that abandons the rest mid-migration.
#
# The report idiom is 'action && say "did X" || warn/die': say and warn always
# return 0, so the C-may-run-when-A-is-true trap SC2015 warns about cannot fire
# on those lines (same reasoning as drill.sh's ok/no).
# shellcheck disable=SC2015
set -u
GW_NEW=10.88.0.1
say() { printf 'migrate: %s\n' "$*"; }
warn() { printf 'migrate: WARNING: %s\n' "$*" >&2; }
die() { printf 'migrate: ERROR: %s\n' "$*" >&2; exit 1; }
@ -86,9 +90,9 @@ rehome_one() {
# 3. VERIFY THE EFFECT, not the exit codes (the whole repo's lesson). The box
# must be on 10.88 and actually resolve+reach the internet on its new leg
# before we call it migrated.
local i ip
local _i ip
ip=""
for i in $(seq 1 30); do
for _i in $(seq 1 30); do
ip="$(incus exec "$b" -- ip -4 -o addr show scope global </dev/null 2>/dev/null \
| awk '{for(i=1;i<NF;i++) if($i=="inet" && $(i+1)~/^10\.88\./){split($(i+1),a,"/"); print a[1]; exit}}')"
[ -n "$ip" ] && break

146
test/cli.sh Normal file
View file

@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Dependency-free CLI assertions for box. Run: bash test/cli.sh
#
# Runnable by a NON-root user with NO Incus installed — that is the whole point.
# Anything that needs a real incus daemon (every lifecycle command) is proven the
# way rig proves its root-only paths: source the pure function and drive it against
# a fixture, or grep the load-bearing line so a deleted guard cannot ship green.
# Deliberately no `set -e` — the harness asserts on failing commands.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PASS=0 FAIL=0
# check <desc> <want_exit> <want_substr> <cmd...>
# Runs cmd, asserts exit code and (if non-empty) that combined output
# contains want_substr.
check() {
local desc="$1" want="$2" substr="$3"; shift 3
local out rc
out="$("$@" 2>&1)"; rc=$?
if [ "$rc" -ne "$want" ]; then
echo "FAIL: $desc — exit $rc, wanted $want"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
echo "FAIL: $desc — output missing '$substr'"
printf '%s\n' "$out" | sed 's/^/ /'
FAIL=$((FAIL + 1)); return
fi
echo "ok: $desc"; PASS=$((PASS + 1))
}
BOX="$ROOT/bin/box"
# ---------------------------------------------------------------------------
# The CLI contract: dispatch, help, usage errors. No incus needed — these all
# resolve before any daemon call. Exit codes are box's own (0 ok / 1 wrong /
# 2 you-asked-wrong), read straight from bin/box and confirmed by running it.
# ---------------------------------------------------------------------------
# box with no args is 'help' (cmd="${1:-help}"), which prints the general usage
# and exits 0 — NOT rig's exit-2 bare-usage. Assert box's actual contract.
check "no args → general help, exit 0" 0 "USAGE" "$BOX"
check "no args help names the command form" 0 "box <command>" "$BOX"
check "--help exits 0" 0 "USAGE" "$BOX" --help
check "-h exits 0" 0 "USAGE" "$BOX" -h
check "help exits 0" 0 "USAGE" "$BOX" help
check "help <command> → that command's usage" 0 "usage: box new" "$BOX" help new
check "--version exits 0" 0 "box" "$BOX" --version
# Unknown command is a usage error (2), and it says so — the suggester may add a
# 'did you mean', but the stem is stable.
check "unknown command exits 2" 2 "unknown command" "$BOX" frobnicate
check "unknown command points at help" 2 "box help" "$BOX" zzzzzz
# Options before the command are the classic mistake; box names the fix.
check "option before command exits 2" 2 "options come after the command" "$BOX" --json list
# A missing required positional is a usage error carrying that command's synopsis.
check "new without --name exits 2" 2 "usage: box new" "$BOX" new
check "shell without a box exits 2" 2 "usage: box shell" "$BOX" shell
check "restore without arg2 needs a box first" 2 "usage: box restore" "$BOX" restore
# An unknown flag is refused, not swallowed as a positional (the --labl bug).
check "unknown flag on list exits 2" 2 "unknown option" "$BOX" list --nope
# A flag that needs a value and gets none.
check "--name with no value exits 2" 2 "--name needs a value" "$BOX" new --name
# ---------------------------------------------------------------------------
# A shim `id` on PATH: lets us drive install.sh's DEST branch with a canned uid +
# group output, exactly the way rig drives assert_runner_repo against fixtures.
# ---------------------------------------------------------------------------
SHIMDIR="$(mktemp -d)"
cat > "$SHIMDIR/id" <<'SHIM'
#!/usr/bin/env bash
# Fake `id`: -u prints $FAKE_UID, -nG prints $FAKE_GROUPS. Just enough for
# install.sh's DEST branch, which only ever asks these two.
case "${1:-}" in
-u) printf '%s\n' "${FAKE_UID:-1000}" ;;
-nG) printf '%s\n' "${FAKE_GROUPS:-}" ;;
*) exit 0 ;;
esac
SHIM
chmod +x "$SHIMDIR/id"
# ---------------------------------------------------------------------------
# install.sh — #71 global/root install. bash -n first, then drive the actual
# DEST/BINDIR branch with the shim id (the functional proof the contract asks
# for), then grep the root-only pieces that a daemon-free run cannot exercise.
# ---------------------------------------------------------------------------
check "install.sh is valid bash" 0 "" bash -n "$ROOT/install.sh"
# Extract EXACTLY the DEST/BINDIR if/else/fi (the first `id -u -eq 0` block) and
# print what it resolved — the same "run the pure block in isolation" trick rig
# uses for its embedded dump script. Fail closed: a mangled extraction is caught
# by the /opt/box grep below before any resolution is trusted.
DBLOCK="$(mktemp)"
awk '/id -u.*-eq 0/{f=1} f{print} f&&/^fi$/{exit}' "$ROOT/install.sh" > "$DBLOCK"
# The $DEST/$BINDIR here are LITERAL text appended into the extracted block — they
# must expand when that block RUNS, not when this printf writes it. Hence single
# quotes; SC2016 is the intent.
# shellcheck disable=SC2016
printf '\nprintf "DEST=%%s BINDIR=%%s\\n" "$DEST" "$BINDIR"\n' >> "$DBLOCK"
check "install.sh: DEST block extracted (guards the awk)" 0 "/opt/box" cat "$DBLOCK"
check "install.sh: the extracted DEST block is valid bash" 0 "" bash -n "$DBLOCK"
dest() { # dest <uid> [extra env assignments...] — resolve DEST/BINDIR
local uid="$1"; shift
FAKE_UID="$uid" HOME=/home/tester PATH="$SHIMDIR:$PATH" env "$@" bash "$DBLOCK"
}
# Root: the global path — a system tree other users can read (#71).
check "install.sh: root → DEST=/opt/box" 0 "DEST=/opt/box" dest 0
check "install.sh: root → BINDIR=/usr/local/bin" 0 "BINDIR=/usr/local/bin" dest 0
# Non-root: unchanged, the solo path.
check "install.sh: non-root → DEST=\$HOME/.local" 0 "DEST=/home/tester/.local/share/box" dest 1000
check "install.sh: non-root → BINDIR=\$HOME/.local" 0 "BINDIR=/home/tester/.local/bin" dest 1000
# BOX_HOME / BOX_BIN still win on BOTH branches — the scripting override.
check "install.sh: BOX_HOME overrides the root default" 0 "DEST=/srv/box" dest 0 BOX_HOME=/srv/box
check "install.sh: BOX_BIN overrides the root default" 0 "BINDIR=/srv/bin" dest 0 BOX_BIN=/srv/bin
check "install.sh: BOX_HOME overrides the non-root default" 0 "DEST=/srv/box" dest 1000 BOX_HOME=/srv/box
rm -f "$DBLOCK"
# The root-only world-readable chmod (#71): the tree is EXECUTED by other users,
# so root must open read+traverse. Grep it, and that it is root-guarded so the
# per-user install stays byte-identical to before.
# $DEST is a LITERAL in the grep pattern (install.sh's own variable) — single
# quotes intended.
# shellcheck disable=SC2016
check "install.sh: root makes the tree world-readable (a+rX)" 0 "" \
grep -qF 'chmod -R a+rX "$DEST"' "$ROOT/install.sh"
check "install.sh: the a+rX is root-guarded" 0 "" \
bash -c 'grep -B2 "chmod -R a+rX" "'"$ROOT"'/install.sh" | grep -q "id -u.*-eq 0"'
# #66's flow, preserved: confirm-before-download, and no-op if already installed.
check "install.sh: still confirms before downloading (#66)" 0 "" \
grep -qF 'confirm "Install box from' "$ROOT/install.sh"
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.
# ---------------------------------------------------------------------------
for t in blank claude codex grok; do
check "template '$t': installs tmux (#65)" 0 "" \
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$ROOT/templates/$t/user-data.yaml"
done
echo "---"
echo "$PASS passed, $FAIL failed"
rm -rf "$SHIMDIR"
[ "$FAIL" -eq 0 ]