forked from heavy-duty/box
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:
parent
9849021b6a
commit
437a3a8e35
6 changed files with 209 additions and 5 deletions
32
.github/workflows/ci.yml
vendored
Normal file
32
.github/workflows/ci.yml
vendored
Normal 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
16
bin/box
|
|
@ -641,6 +641,13 @@ load_template() {
|
||||||
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
|
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
|
||||||
esac
|
esac
|
||||||
val="${val#\"}"; val="${val%\"}"
|
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
|
case "$key" in
|
||||||
BOX_DESCRIPTION) T_DESC="$val" ;;
|
BOX_DESCRIPTION) T_DESC="$val" ;;
|
||||||
BOX_IMAGE) T_IMAGE="$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)" ;;
|
*) 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
|
esac
|
||||||
done <"$dir/box.env"
|
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)
|
# Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
|
||||||
# > BOX_* environment (how a small host or the drill shrinks every box it
|
# > BOX_* environment (how a small host or the drill shrinks every box it
|
||||||
# mints) > the template's file > defaults. Values pass to Incus verbatim —
|
# 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)"
|
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
|
||||||
[ -n "$pfx" ] || return 1
|
[ -n "$pfx" ] || return 1
|
||||||
incus list "$1" --format csv --columns 4 2>/dev/null \
|
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.
|
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@
|
||||||
#
|
#
|
||||||
# This script is the answer to "what state is the host actually in?" — the
|
# This script is the answer to "what state is the host actually in?" — the
|
||||||
# question that kept getting answered by hand.
|
# 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
|
set -u
|
||||||
|
|
||||||
FIX=0; PIN=0
|
FIX=0; PIN=0
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@
|
||||||
# NOT 'set -e': on a wipe, a step that finds nothing to remove is success,
|
# 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
|
# not failure. Every removal states what it did; silence is never trusted
|
||||||
# (the exit-code lesson, again).
|
# (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
|
set -u
|
||||||
|
|
||||||
YES=0; PURGE_STORAGE=0
|
YES=0; PURGE_STORAGE=0
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,13 @@
|
||||||
#
|
#
|
||||||
# NOT 'set -e' around the per-box work: a box that fails one step is reported
|
# 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.
|
# 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
|
set -u
|
||||||
|
|
||||||
GW_NEW=10.88.0.1
|
|
||||||
say() { printf 'migrate: %s\n' "$*"; }
|
say() { printf 'migrate: %s\n' "$*"; }
|
||||||
warn() { printf 'migrate: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'migrate: WARNING: %s\n' "$*" >&2; }
|
||||||
die() { printf 'migrate: ERROR: %s\n' "$*" >&2; exit 1; }
|
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
|
# 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
|
# must be on 10.88 and actually resolve+reach the internet on its new leg
|
||||||
# before we call it migrated.
|
# before we call it migrated.
|
||||||
local i ip
|
local _i ip
|
||||||
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 \
|
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}}')"
|
| 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
|
[ -n "$ip" ] && break
|
||||||
|
|
|
||||||
146
test/cli.sh
Normal file
146
test/cli.sh
Normal 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 ]
|
||||||
Loading…
Reference in a new issue