A VM name flows into five places that all have narrower grammars
than "arbitrary string":
- the guest's /etc/hostname (vm_disk.patchRootOverlay)
- the guest's /etc/hosts (same)
- the <name>.vm DNS record (vmdns.RecordName)
- the kernel command line (system.BuildBootArgs*)
- VM-dir file-path fragments (layout.VMsDir/<id>, etc.)
Nothing in the chain was validating the input. A name with
whitespace, newline, dot, slash, colon, or = would produce broken
hostnames, weird DNS labels, smuggled kernel cmdline tokens, or
(in the worst case) surprising traversal through the on-disk
layout. Not host shell injection — we already avoid shelling out
with the raw name — but a real correctness and supportability bug.
New: model.ValidateVMName. Rules:
- 1..63 chars (DNS label max per RFC 1123; also a comfortable
/etc/hostname cap)
- lowercase ASCII letters, digits, '-' only
- no leading or trailing '-'
- no normalization — the name is the user-visible identifier
(store key, `ssh <name>.vm`, `vm show`); silently rewriting
"MyVM" → "myvm" would hand the user back something different
than they typed
Called from two places:
- internal/cli/commands_vm.go vmCreateParamsFromFlags — rejects
bad `--name` values before any RPC. Empty name still passes
through so the daemon can generate one.
- internal/daemon/vm_create.go reserveVM — defense in depth for
any non-CLI RPC caller (SDK, direct JSON over the socket).
Tests:
- internal/model/vm_name_test.go — exhaustive character-class
matrix (space, newline, tab, dot, slash, colon, equals, quote,
control chars, unicode letters, uppercase, leading/trailing
hyphen, over-length, max-length-exact, digits-only).
- internal/cli TestVMCreateParamsFromFlagsRejectsInvalidName —
CLI wire-through + empty-name passthrough.
- internal/daemon TestReserveVMRejectsInvalidName — daemon
defense-in-depth (including `box/../evil` path-traversal).
- scripts/smoke.sh — end-to-end rejection + no-leaked-row
assertion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
592 lines
29 KiB
Bash
Executable file
592 lines
29 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
#
|
|
# scripts/smoke.sh — end-to-end smoke suite for banger.
|
|
#
|
|
# Drives a real create → start → ssh → exec → delete cycle against
|
|
# real Firecracker + real KVM on the host. Intended as a pre-release
|
|
# gate: the Go unit + integration tests don't and can't cover the
|
|
# post-machine.Start path (socket ownership, guest boot, vsock agent
|
|
# wait, guest SSH, workspace prepare). If this suite fails, don't
|
|
# ship.
|
|
#
|
|
# State lives under $BANGER_SMOKE_XDG_DIR (set by `make smoke`,
|
|
# defaults to build/smoke/xdg). It's ISOLATED from the invoking
|
|
# user's real banger install via XDG_{CONFIG,STATE,CACHE,RUNTIME}
|
|
# overrides, but PERSISTED across runs — so the first smoke pulls
|
|
# the golden image, subsequent smokes reuse it. `make smoke-clean`
|
|
# wipes it.
|
|
#
|
|
# Invoked via `make smoke`, which sets the three env vars below.
|
|
# Don't run this directly unless you know they're set.
|
|
|
|
set -euo pipefail
|
|
|
|
log() { printf '[smoke] %s\n' "$*" >&2; }
|
|
die() { printf '[smoke] FAIL: %s\n' "$*" >&2; exit 1; }
|
|
|
|
# wait_for_ssh polls `vm ssh <vm> -- true` until it succeeds or the
|
|
# timeout expires. `vm ssh` — unlike `vm run` — does not itself wait
|
|
# for guest sshd, so scenarios that call `vm create` / `vm start`
|
|
# back-to-back with `vm ssh` need this shim. 60s matches
|
|
# vmRunSSHTimeout.
|
|
wait_for_ssh() {
|
|
local vm="$1"
|
|
local deadline=$(( $(date +%s) + 60 ))
|
|
while (( $(date +%s) < deadline )); do
|
|
if "$BANGER" vm ssh "$vm" -- true >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
: "${BANGER_SMOKE_BIN_DIR:?must point at the instrumented binary dir, set by make smoke}"
|
|
: "${BANGER_SMOKE_COVER_DIR:?must point at the coverage dir, set by make smoke}"
|
|
: "${BANGER_SMOKE_XDG_DIR:?must point at the isolated XDG root, set by make smoke}"
|
|
|
|
BANGER="$BANGER_SMOKE_BIN_DIR/banger"
|
|
BANGERD="$BANGER_SMOKE_BIN_DIR/bangerd"
|
|
VSOCK_AGENT="$BANGER_SMOKE_BIN_DIR/banger-vsock-agent"
|
|
|
|
for bin in "$BANGER" "$BANGERD" "$VSOCK_AGENT"; do
|
|
[[ -x "$bin" ]] || die "binary missing or not executable: $bin"
|
|
done
|
|
|
|
# Persistent XDG dirs (state, cache, config) so repeated smoke
|
|
# runs reuse the pulled golden image instead of re-downloading
|
|
# ~300MB each time. Runtime dir needs to be fresh per-run because
|
|
# it holds sockets the daemon cleans up on stop and refuses to
|
|
# reuse if any are stale.
|
|
mkdir -p \
|
|
"$BANGER_SMOKE_XDG_DIR/config" \
|
|
"$BANGER_SMOKE_XDG_DIR/state" \
|
|
"$BANGER_SMOKE_XDG_DIR/cache"
|
|
runtime_dir="$(mktemp -d -t banger-smoke-runtime-XXXXXX)"
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
chmod 0700 "$runtime_dir"
|
|
|
|
export XDG_CONFIG_HOME="$BANGER_SMOKE_XDG_DIR/config"
|
|
export XDG_STATE_HOME="$BANGER_SMOKE_XDG_DIR/state"
|
|
export XDG_CACHE_HOME="$BANGER_SMOKE_XDG_DIR/cache"
|
|
export XDG_RUNTIME_DIR="$runtime_dir"
|
|
|
|
# Point banger at its companion binaries inside the smoke build.
|
|
export BANGER_DAEMON_BIN="$BANGERD"
|
|
export BANGER_VSOCK_AGENT_BIN="$VSOCK_AGENT"
|
|
|
|
# Instrumented binaries dump coverage here on clean exit.
|
|
export GOCOVERDIR="$BANGER_SMOKE_COVER_DIR"
|
|
mkdir -p "$GOCOVERDIR"
|
|
|
|
# Any smoke daemon left behind from a prior run that crashed mid-
|
|
# scenario would reuse the stale socket path and confuse
|
|
# ensureDaemon. Best-effort stop; ignore if nothing is running.
|
|
"$BANGER" daemon stop >/dev/null 2>&1 || true
|
|
|
|
# banger's vmDNS binds 127.0.0.1:42069 (UDP) hard. If the user's
|
|
# real (non-smoke) daemon is running, its listener holds the port
|
|
# and the smoke daemon's Open() fails before any scenario runs.
|
|
# Fail fast with an actionable message — don't guess whether to
|
|
# stop the user's daemon for them.
|
|
if command -v ss >/dev/null 2>&1 && ss -Huln 2>/dev/null | awk '{print $4}' | grep -q '[:.]42069$'; then
|
|
die 'port 127.0.0.1:42069 is already bound (likely your real banger daemon); stop it with `banger daemon stop` and re-run `make smoke`'
|
|
fi
|
|
|
|
# --- doctor -----------------------------------------------------------
|
|
log 'doctor: checking host readiness'
|
|
if ! "$BANGER" doctor; then
|
|
die 'doctor reported failures; fix the host before running smoke'
|
|
fi
|
|
|
|
# --- bare vm run ------------------------------------------------------
|
|
log "bare vm run: create + start + ssh + exec 'echo smoke-bare-ok' + --rm"
|
|
bare_out="$("$BANGER" vm run --rm -- echo smoke-bare-ok)" || die "bare vm run exit $?"
|
|
grep -q 'smoke-bare-ok' <<<"$bare_out" || die "bare vm run stdout missing marker: $bare_out"
|
|
|
|
# --- workspace vm run -------------------------------------------------
|
|
log 'workspace vm run: preparing a throwaway git repo'
|
|
repodir="$runtime_dir/fake-repo"
|
|
mkdir -p "$repodir"
|
|
(
|
|
cd "$repodir"
|
|
git init -q -b main
|
|
git config commit.gpgsign false
|
|
git config user.name smoke
|
|
git config user.email smoke@smoke
|
|
echo 'smoke-workspace-marker' > smoke-file.txt
|
|
git add .
|
|
git commit -q -m init
|
|
)
|
|
|
|
log "workspace vm run: create + start + workspace prepare + cat guest file + --rm"
|
|
ws_out="$("$BANGER" vm run --rm "$repodir" -- cat /root/repo/smoke-file.txt)" || die "workspace vm run exit $?"
|
|
grep -q 'smoke-workspace-marker' <<<"$ws_out" || die "workspace vm run didn't ship smoke-file.txt: $ws_out"
|
|
|
|
# --- command exit-code propagation ------------------------------------
|
|
# A non-zero exit from the guest command must surface as banger's own
|
|
# exit code. Regressions here are hard to catch any other way — the
|
|
# local Go tests don't cross the SSH boundary, and users expect their
|
|
# CI scripts that wrap `banger vm run` to fail when the thing inside
|
|
# the VM failed.
|
|
log 'exit-code propagation: guest `sh -c "exit 42"` must produce rc=42'
|
|
set +e
|
|
"$BANGER" vm run --rm -- sh -c 'exit 42'
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -eq 42 ]] || die "exit-code propagation: got rc=$rc, want 42"
|
|
|
|
# --- workspace dry-run (no VM) ----------------------------------------
|
|
# Pure CLI-side path — no VM, no sudo, just the local git inspection
|
|
# against d.repoInspector. Fast; catches regressions in the preview
|
|
# output (file list shape, mode line) that the Go tests already pin
|
|
# but that could still be broken by a client-side wiring change.
|
|
log 'workspace dry-run: list tracked files without creating a VM'
|
|
dry_out="$("$BANGER" vm run --dry-run "$repodir")" || die "dry-run exit $?"
|
|
grep -q 'smoke-file.txt' <<<"$dry_out" || die "dry-run didn't list smoke-file.txt: $dry_out"
|
|
grep -q 'mode: tracked only' <<<"$dry_out" || die "dry-run mode line missing or wrong: $dry_out"
|
|
|
|
# --- workspace --include-untracked -----------------------------------
|
|
# The default is tracked-only (review cycle 4). Opt-in must ship
|
|
# untracked files too. Write one, run with --include-untracked, verify
|
|
# it reaches the guest.
|
|
log 'workspace --include-untracked: opt-in ships files outside the git index'
|
|
echo 'untracked-marker' > "$repodir/smoke-untracked.txt"
|
|
inc_out="$("$BANGER" vm run --rm --include-untracked "$repodir" -- cat /root/repo/smoke-untracked.txt)" || die "include-untracked vm run exit $?"
|
|
grep -q 'untracked-marker' <<<"$inc_out" || die "--include-untracked didn't ship the untracked file: $inc_out"
|
|
# Restore repo to tracked-only state for any later scenarios.
|
|
rm -f "$repodir/smoke-untracked.txt"
|
|
|
|
# --- workspace export round-trip --------------------------------------
|
|
# Exercises ExportVMWorkspace: create a VM, prepare the workspace,
|
|
# write a new file inside the guest, then export and assert the
|
|
# emitted patch sees the guest-side change. If the export pipeline
|
|
# (temp-index, git add -A, diff --binary) ever stops capturing
|
|
# guest-side changes, this scenario catches it.
|
|
log 'workspace export: create + prepare + guest edit + export + assert marker'
|
|
export_vm='smoke-export'
|
|
cleanup_export_vm() {
|
|
"$BANGER" vm delete "$export_vm" >/dev/null 2>&1 || true
|
|
}
|
|
# Chain the VM cleanup with the existing runtime_dir trap so a mid-
|
|
# scenario failure still tears the VM down before the script exits.
|
|
# shellcheck disable=SC2064
|
|
trap "cleanup_export_vm; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name "$export_vm" --image debian-bookworm >/dev/null \
|
|
|| die "export: vm create exit $?"
|
|
"$BANGER" vm workspace prepare "$export_vm" "$repodir" >/dev/null \
|
|
|| die "export: workspace prepare exit $?"
|
|
"$BANGER" vm ssh "$export_vm" -- sh -c 'echo guest-edit > /root/repo/new-guest-file.txt' \
|
|
|| die "export: guest-side file write exit $?"
|
|
export_patch="$runtime_dir/smoke-export.diff"
|
|
"$BANGER" vm workspace export "$export_vm" --output "$export_patch" \
|
|
|| die "export: workspace export exit $?"
|
|
[[ -s "$export_patch" ]] || die "export: patch file empty at $export_patch"
|
|
grep -q 'new-guest-file.txt' "$export_patch" \
|
|
|| die "export: patch missing new-guest-file.txt marker (head: $(head -c 400 "$export_patch"))"
|
|
|
|
cleanup_export_vm
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- concurrent vm runs -----------------------------------------------
|
|
# Stresses per-VM lock scoping, the tap pool warm-up path, and
|
|
# createVMMu's narrow reservation window. Two `vm run --rm` invocations
|
|
# that actually overlap should both succeed. A regression that
|
|
# serialises create path too aggressively would make this slow but
|
|
# still pass; a regression that breaks tap allocation or name
|
|
# uniqueness would fail one of them.
|
|
log 'concurrent vm runs: two --rm invocations must both succeed'
|
|
tmpA="$runtime_dir/concurrent-a.out"
|
|
tmpB="$runtime_dir/concurrent-b.out"
|
|
"$BANGER" vm run --rm -- echo smoke-concurrent-a > "$tmpA" 2>&1 &
|
|
pidA=$!
|
|
"$BANGER" vm run --rm -- echo smoke-concurrent-b > "$tmpB" 2>&1 &
|
|
pidB=$!
|
|
wait "$pidA" || die "concurrent VM A exited non-zero: $(cat "$tmpA")"
|
|
wait "$pidB" || die "concurrent VM B exited non-zero: $(cat "$tmpB")"
|
|
grep -q 'smoke-concurrent-a' "$tmpA" || die "concurrent VM A missing marker: $(cat "$tmpA")"
|
|
grep -q 'smoke-concurrent-b' "$tmpB" || die "concurrent VM B missing marker: $(cat "$tmpB")"
|
|
|
|
# --- vm lifecycle (create → stop → start → delete) --------------------
|
|
# Exercises lifecycle verbs directly instead of the --rm convenience
|
|
# path. The critical assertion is the second `vm ssh` AFTER stop/start:
|
|
# that path (a) rebuilds the handle cache via rediscoverHandles,
|
|
# (b) runs the e2fsck-snapshot sanitize step before patchRootOverlay
|
|
# on the dirty COW, and (c) shouldn't die from the SDK's
|
|
# ctx-SIGTERM-on-RPC-close goroutine. All three were bugs at one
|
|
# point; this scenario guards all three at once.
|
|
log 'vm lifecycle: explicit create / stop / start / ssh / delete'
|
|
lifecycle_name=smoke-lifecycle
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete $lifecycle_name >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name "$lifecycle_name" >/dev/null || die "vm create $lifecycle_name failed"
|
|
show_out="$("$BANGER" vm show "$lifecycle_name")" || die "vm show after create failed"
|
|
grep -q '"state": "running"' <<<"$show_out" || die "post-create state not running: $show_out"
|
|
|
|
wait_for_ssh "$lifecycle_name" || die 'vm lifecycle: ssh did not come up after create'
|
|
ssh_out="$("$BANGER" vm ssh "$lifecycle_name" -- echo hello-1)" || die "vm ssh #1 failed"
|
|
grep -q 'hello-1' <<<"$ssh_out" || die "vm ssh #1 missing marker: $ssh_out"
|
|
|
|
"$BANGER" vm stop "$lifecycle_name" >/dev/null || die "vm stop failed"
|
|
show_out="$("$BANGER" vm show "$lifecycle_name")" || die "vm show after stop failed"
|
|
grep -q '"state": "stopped"' <<<"$show_out" || die "post-stop state not stopped: $show_out"
|
|
|
|
"$BANGER" vm start "$lifecycle_name" >/dev/null || die "vm start (from stopped) failed"
|
|
show_out="$("$BANGER" vm show "$lifecycle_name")" || die "vm show after start failed"
|
|
grep -q '"state": "running"' <<<"$show_out" || die "post-start state not running: $show_out"
|
|
|
|
wait_for_ssh "$lifecycle_name" || die 'vm lifecycle: ssh did not come up after restart'
|
|
ssh_out="$("$BANGER" vm ssh "$lifecycle_name" -- echo hello-2)" || die "vm ssh #2 (post-restart) failed"
|
|
grep -q 'hello-2' <<<"$ssh_out" || die "vm ssh #2 missing marker: $ssh_out"
|
|
|
|
"$BANGER" vm delete "$lifecycle_name" >/dev/null || die "vm delete failed"
|
|
set +e
|
|
"$BANGER" vm show "$lifecycle_name" >/dev/null 2>&1
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -ne 0 ]] || die "vm show still finds $lifecycle_name after delete"
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- vm set reconfiguration (vcpu change + restart) -------------------
|
|
# Exercises SetVM + configChangeCapability. Create with --vcpu 2,
|
|
# stop, `vm set --vcpu 4`, restart, confirm the guest sees the new
|
|
# count. Regression guard: a restart that reuses the pre-change spec
|
|
# would leave nproc at 2.
|
|
log 'vm set: create --vcpu 2 → stop → set --vcpu 4 → restart → nproc=4'
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete smoke-set >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-set --vcpu 2 >/dev/null || die 'vm set: create failed'
|
|
wait_for_ssh smoke-set || die 'vm set: initial ssh did not come up'
|
|
|
|
set +e
|
|
nproc_before="$("$BANGER" vm ssh smoke-set -- nproc 2>/dev/null)"
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -eq 0 ]] || die "vm set: initial nproc ssh exit $rc"
|
|
[[ "$(printf '%s' "$nproc_before" | tr -d '[:space:]')" == "2" ]] \
|
|
|| die "vm set: initial nproc got '$nproc_before', want 2"
|
|
|
|
"$BANGER" vm stop smoke-set >/dev/null || die 'vm set: stop failed'
|
|
"$BANGER" vm set smoke-set --vcpu 4 >/dev/null || die 'vm set: reconfigure failed'
|
|
"$BANGER" vm start smoke-set >/dev/null || die 'vm set: restart failed'
|
|
wait_for_ssh smoke-set || die 'vm set: post-reconfig ssh did not come up'
|
|
|
|
set +e
|
|
nproc_after="$("$BANGER" vm ssh smoke-set -- nproc 2>/dev/null)"
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -eq 0 ]] || die "vm set: post-reconfig nproc ssh exit $rc"
|
|
[[ "$(printf '%s' "$nproc_after" | tr -d '[:space:]')" == "4" ]] \
|
|
|| die "vm set: post-reconfig nproc got '$nproc_after', want 4 (spec change didn't land)"
|
|
|
|
"$BANGER" vm delete smoke-set >/dev/null || die 'vm set: delete failed'
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- vm restart (dedicated verb) --------------------------------------
|
|
# `vm restart` is its own verb, not a stop+start composite at the API
|
|
# level — it must end up with a freshly booted guest. The assertion is
|
|
# a fresh boot ID: /proc/sys/kernel/random/boot_id changes on every
|
|
# kernel boot, so post-restart != pre-restart proves the kernel was
|
|
# actually recycled rather than the verb no-op'ing.
|
|
log 'vm restart: boot_id must change across the verb'
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete smoke-restart >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-restart >/dev/null || die 'vm restart: create failed'
|
|
wait_for_ssh smoke-restart || die 'vm restart: initial ssh never came up'
|
|
boot_before="$("$BANGER" vm ssh smoke-restart -- cat /proc/sys/kernel/random/boot_id | tr -d '[:space:]')"
|
|
[[ -n "$boot_before" ]] || die 'vm restart: could not read initial boot_id'
|
|
|
|
"$BANGER" vm restart smoke-restart >/dev/null || die 'vm restart: verb failed'
|
|
wait_for_ssh smoke-restart || die 'vm restart: ssh did not come up after restart'
|
|
boot_after="$("$BANGER" vm ssh smoke-restart -- cat /proc/sys/kernel/random/boot_id | tr -d '[:space:]')"
|
|
[[ -n "$boot_after" ]] || die 'vm restart: could not read post-restart boot_id'
|
|
[[ "$boot_before" != "$boot_after" ]] \
|
|
|| die "vm restart: boot_id unchanged ($boot_before); verb didn't actually reboot the guest"
|
|
|
|
"$BANGER" vm delete smoke-restart >/dev/null || die 'vm restart: delete failed'
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- vm kill (--signal KILL, forceful path) ---------------------------
|
|
# `vm stop` takes the graceful Ctrl-Alt-Del route. `vm kill --signal
|
|
# KILL` is the explicit "the guest is wedged, drop it" path. It must
|
|
# (a) terminate firecracker, (b) leave the VM record in a stopped
|
|
# state (not 'error'), (c) tear down the dm-snapshot + loops so the
|
|
# next create/start doesn't trip over leftovers.
|
|
log 'vm kill --signal KILL: forceful terminate, state=stopped, no leaked dm device'
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete smoke-kill >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-kill >/dev/null || die 'vm kill: create failed'
|
|
dm_name="$("$BANGER" vm show smoke-kill 2>/dev/null | awk -F'"' '/"dm_dev"|fc-rootfs-/ {for(i=1;i<=NF;i++) if($i~/^fc-rootfs-/) print $i}' | head -1 || true)"
|
|
"$BANGER" vm kill --signal KILL smoke-kill >/dev/null || die 'vm kill: verb failed'
|
|
show_out="$("$BANGER" vm show smoke-kill)" || die 'vm kill: show after kill failed'
|
|
grep -q '"state": "stopped"' <<<"$show_out" || die "vm kill: post-kill state not stopped: $show_out"
|
|
if [[ -n "$dm_name" ]]; then
|
|
if sudo -n dmsetup ls 2>/dev/null | awk '{print $1}' | grep -qx "$dm_name"; then
|
|
die "vm kill: dm device $dm_name still mapped (cleanup didn't run)"
|
|
fi
|
|
fi
|
|
"$BANGER" vm delete smoke-kill >/dev/null || die 'vm kill: delete failed'
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- vm prune (-f) ----------------------------------------------------
|
|
# Create two VMs: one running, one stopped. `vm prune -f` must delete
|
|
# the stopped one and leave the running one alone. Skip interactive
|
|
# confirmation with -f (smoke has no tty). Regression guard: a bug
|
|
# that deleted the running VM would wreck any session the user had.
|
|
log 'vm prune -f: removes stopped VMs, preserves running ones'
|
|
cleanup_prune() {
|
|
"$BANGER" vm delete smoke-prune-running >/dev/null 2>&1 || true
|
|
"$BANGER" vm delete smoke-prune-stopped >/dev/null 2>&1 || true
|
|
}
|
|
# shellcheck disable=SC2064
|
|
trap "cleanup_prune; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-prune-running >/dev/null || die 'vm prune: create running failed'
|
|
"$BANGER" vm create --name smoke-prune-stopped >/dev/null || die 'vm prune: create stopped failed'
|
|
"$BANGER" vm stop smoke-prune-stopped >/dev/null || die 'vm prune: stop the stopped one failed'
|
|
|
|
"$BANGER" vm prune -f >/dev/null || die 'vm prune: verb failed'
|
|
|
|
"$BANGER" vm show smoke-prune-running >/dev/null 2>&1 || die 'vm prune: running VM was deleted (regression!)'
|
|
if "$BANGER" vm show smoke-prune-stopped >/dev/null 2>&1; then
|
|
die 'vm prune: stopped VM survived prune'
|
|
fi
|
|
|
|
"$BANGER" vm delete smoke-prune-running >/dev/null || die 'vm prune: cleanup delete failed'
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- vm ports ---------------------------------------------------------
|
|
# sshd binds :22 in every guest — it's the minimum promise of a VM.
|
|
# If `vm ports` can't see that, the host→guest port visibility pipe
|
|
# (vsock-agent on-demand query, daemon aggregation, CLI rendering) is
|
|
# broken. Endpoint shape is also asserted: daemon prefers the
|
|
# <name>.vm DNS record over the raw guest IP, so we grep for the
|
|
# name form.
|
|
log 'vm ports: sshd :22 visible from host, endpoint uses the VM DNS name'
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete smoke-ports >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-ports >/dev/null || die 'vm ports: create failed'
|
|
wait_for_ssh smoke-ports || die 'vm ports: ssh did not come up'
|
|
|
|
ports_out="$("$BANGER" vm ports smoke-ports 2>&1)" \
|
|
|| die "vm ports: verb failed: $ports_out"
|
|
grep -q 'smoke-ports.vm:22' <<<"$ports_out" \
|
|
|| die "vm ports: expected 'smoke-ports.vm:22' in output; got: $ports_out"
|
|
grep -q 'sshd' <<<"$ports_out" \
|
|
|| die "vm ports: expected process 'sshd' in output; got: $ports_out"
|
|
|
|
"$BANGER" vm delete smoke-ports >/dev/null || die 'vm ports: delete failed'
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- workspace prepare --mode full_copy -------------------------------
|
|
# Default mode is shallow_overlay. full_copy copies the repo via a
|
|
# different transfer path (tar stream into the guest's rootfs with
|
|
# no overlay). Smoke asserts it still lands the content at the same
|
|
# guest path.
|
|
log 'workspace prepare --mode full_copy: alternate transfer path still delivers'
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete smoke-fc >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-fc >/dev/null || die 'workspace fc: create failed'
|
|
"$BANGER" vm workspace prepare smoke-fc "$repodir" --mode full_copy >/dev/null \
|
|
|| die 'workspace fc: prepare --mode full_copy failed'
|
|
fc_out="$("$BANGER" vm ssh smoke-fc -- cat /root/repo/smoke-file.txt)" \
|
|
|| die 'workspace fc: guest read failed'
|
|
grep -q 'smoke-workspace-marker' <<<"$fc_out" \
|
|
|| die "workspace fc: marker missing in full_copy workspace: $fc_out"
|
|
|
|
"$BANGER" vm delete smoke-fc >/dev/null || die 'workspace fc: delete failed'
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- workspace export --base-commit (committed guest delta) -----------
|
|
# Without --base-commit, export diffs the worktree against HEAD — it
|
|
# misses commits the worker made inside the guest (because the guest
|
|
# HEAD advanced). With --base-commit pinned at the prepare-time SHA,
|
|
# those commits land in the patch. This is the happy path the feature
|
|
# was added for; zero coverage until now.
|
|
log 'workspace export --base-commit: guest-side commits captured in patch'
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete smoke-basecommit >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-basecommit >/dev/null || die 'export base: create failed'
|
|
"$BANGER" vm workspace prepare smoke-basecommit "$repodir" >/dev/null \
|
|
|| die 'export base: prepare failed'
|
|
|
|
# Capture the prepare-time HEAD from the guest directly (same SHA the
|
|
# daemon returns as HeadCommit in the RPC result).
|
|
base_sha="$("$BANGER" vm ssh smoke-basecommit -- sh -c 'cd /root/repo && git rev-parse HEAD' | tr -d '[:space:]')"
|
|
[[ "${#base_sha}" -eq 40 ]] || die "export base: bad base sha: $base_sha"
|
|
|
|
# Make a guest-side commit: new file + git add + git commit. Without
|
|
# --base-commit, this commit would be invisible to a HEAD-relative diff.
|
|
"$BANGER" vm ssh smoke-basecommit -- sh -c "cd /root/repo && git -c user.email=smoke@smoke -c user.name=smoke checkout -b smoke-branch >/dev/null 2>&1 && echo committed-marker > smoke-committed.txt && git add smoke-committed.txt && git -c user.email=smoke@smoke -c user.name=smoke commit -q -m 'guest side'" \
|
|
|| die 'export base: guest-side commit failed'
|
|
|
|
# Control: plain export (no --base-commit) must NOT see the committed file.
|
|
plain_patch="$runtime_dir/smoke-plain.diff"
|
|
"$BANGER" vm workspace export smoke-basecommit --output "$plain_patch" \
|
|
|| die 'export base: plain export failed'
|
|
if grep -q 'smoke-committed.txt' "$plain_patch"; then
|
|
die 'export base: plain export unexpectedly captured the guest-side commit'
|
|
fi
|
|
|
|
# With --base-commit pinned to the pre-commit SHA, the delta appears.
|
|
base_patch="$runtime_dir/smoke-base.diff"
|
|
"$BANGER" vm workspace export smoke-basecommit --base-commit "$base_sha" --output "$base_patch" \
|
|
|| die 'export base: --base-commit export failed'
|
|
[[ -s "$base_patch" ]] || die 'export base: patch file empty'
|
|
grep -q 'smoke-committed.txt' "$base_patch" \
|
|
|| die "export base: --base-commit patch missing committed marker (head: $(head -c 400 "$base_patch"))"
|
|
|
|
"$BANGER" vm delete smoke-basecommit >/dev/null || die 'export base: delete failed'
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- ssh-config install / uninstall (HOME-isolated) -------------------
|
|
# `banger ssh-config --install` edits ~/.ssh/config. Smoke runs under
|
|
# the invoking user, so we isolate by pointing HOME at the smoke XDG
|
|
# dir before the commands run (os.UserHomeDir respects $HOME on
|
|
# Linux). No daemon / VM involved — pure CLI + filesystem surface,
|
|
# exercising the install/status/uninstall code paths end-to-end.
|
|
log 'ssh-config --install / --uninstall: idempotent, survives round-trip'
|
|
fake_home="$BANGER_SMOKE_XDG_DIR/fake-home"
|
|
mkdir -p "$fake_home/.ssh"
|
|
# Seed a pre-existing ~/.ssh/config so install must APPEND, not
|
|
# replace. A bug that clobbered pre-existing content would nuke the
|
|
# user's real config on first run.
|
|
printf 'Host myserver\n HostName example.invalid\n' > "$fake_home/.ssh/config"
|
|
|
|
(
|
|
export HOME="$fake_home"
|
|
"$BANGER" ssh-config --install >/dev/null || die 'ssh-config: install failed'
|
|
grep -q '^Include ' "$fake_home/.ssh/config" \
|
|
|| die "ssh-config: install didn't add Include line to ~/.ssh/config"
|
|
grep -q '^Host myserver' "$fake_home/.ssh/config" \
|
|
|| die 'ssh-config: install clobbered pre-existing content (!!)'
|
|
|
|
# Second install must be idempotent (no duplicate Include lines).
|
|
"$BANGER" ssh-config --install >/dev/null || die 'ssh-config: second install failed'
|
|
include_count="$(grep -c '^Include .*banger' "$fake_home/.ssh/config")"
|
|
[[ "$include_count" == "1" ]] \
|
|
|| die "ssh-config: install not idempotent (Include appeared $include_count times)"
|
|
|
|
"$BANGER" ssh-config --uninstall >/dev/null || die 'ssh-config: uninstall failed'
|
|
if grep -q '^Include .*banger' "$fake_home/.ssh/config"; then
|
|
die 'ssh-config: uninstall left the Include line behind'
|
|
fi
|
|
grep -q '^Host myserver' "$fake_home/.ssh/config" \
|
|
|| die 'ssh-config: uninstall nuked user content (!!)'
|
|
)
|
|
|
|
# --- NAT rule installation (per-VM MASQUERADE) ------------------------
|
|
# `--nat` installs a per-VM iptables POSTROUTING MASQUERADE rule
|
|
# scoped to the guest's /32 (see natCapability). End-to-end curl
|
|
# tests don't work here because the bridge IP and the host's uplink
|
|
# IP both belong to the host — a guest reaching the uplink address
|
|
# lands on the host's local loopback whether MASQUERADE is set up
|
|
# or not. So assert the rule itself: NAT VM gets a POSTROUTING
|
|
# MASQUERADE, non-NAT VM does not. This catches the two most
|
|
# plausible regressions (rule never installed; rule not scoped to
|
|
# the right VM) without depending on an external reachable host.
|
|
log 'NAT: --nat installs a per-VM MASQUERADE rule; no --nat means no rule'
|
|
if ! sudo -n iptables -t nat -S POSTROUTING >/dev/null 2>&1; then
|
|
log 'NAT: skipping — passwordless sudo iptables unavailable'
|
|
else
|
|
# shellcheck disable=SC2064
|
|
trap "\"$BANGER\" vm delete smoke-nat >/dev/null 2>&1 || true; \"$BANGER\" vm delete smoke-nocnat >/dev/null 2>&1 || true; rm -rf '$runtime_dir'" EXIT
|
|
|
|
"$BANGER" vm create --name smoke-nat --nat >/dev/null || die 'NAT: create --nat failed'
|
|
"$BANGER" vm create --name smoke-nocnat >/dev/null || die 'NAT: control create failed'
|
|
|
|
nat_ip="$("$BANGER" vm show smoke-nat 2>/dev/null | awk -F'"' '/"guest_ip"/ {print $4}')"
|
|
ctl_ip="$("$BANGER" vm show smoke-nocnat 2>/dev/null | awk -F'"' '/"guest_ip"/ {print $4}')"
|
|
[[ -n "$nat_ip" && -n "$ctl_ip" ]] || die "NAT: couldn't read guest IPs (nat='$nat_ip', ctl='$ctl_ip')"
|
|
|
|
postrouting="$(sudo -n iptables -t nat -S POSTROUTING 2>/dev/null || true)"
|
|
grep -q -- "-s $nat_ip/32.*-j MASQUERADE" <<<"$postrouting" \
|
|
|| die "NAT: --nat VM has no POSTROUTING MASQUERADE rule for $nat_ip; got:"$'\n'"$postrouting"
|
|
if grep -q -- "-s $ctl_ip/32.*-j MASQUERADE" <<<"$postrouting"; then
|
|
die "NAT: control VM unexpectedly has a MASQUERADE rule for $ctl_ip"
|
|
fi
|
|
|
|
# Stop + start the --nat VM to exercise the install-is-idempotent
|
|
# path (capability runs again on each start; a buggy add-without-
|
|
# check would leave two identical rules behind).
|
|
"$BANGER" vm stop smoke-nat >/dev/null || die 'NAT: stop --nat VM failed'
|
|
"$BANGER" vm start smoke-nat >/dev/null || die 'NAT: restart --nat VM failed'
|
|
postrouting="$(sudo -n iptables -t nat -S POSTROUTING 2>/dev/null || true)"
|
|
rule_count="$(grep -c -- "-s $nat_ip/32.*-j MASQUERADE" <<<"$postrouting" || true)"
|
|
[[ "$rule_count" == "1" ]] \
|
|
|| die "NAT: MASQUERADE rule count for $nat_ip = $rule_count after restart, want 1"
|
|
|
|
# Delete must tear the rule down — regression guard against leaks.
|
|
"$BANGER" vm delete smoke-nat >/dev/null || die 'NAT: delete --nat VM failed'
|
|
"$BANGER" vm delete smoke-nocnat >/dev/null || die 'NAT: delete control VM failed'
|
|
postrouting="$(sudo -n iptables -t nat -S POSTROUTING 2>/dev/null || true)"
|
|
if grep -q -- "-s $nat_ip/32.*-j MASQUERADE" <<<"$postrouting"; then
|
|
die "NAT: delete left a MASQUERADE rule behind for $nat_ip"
|
|
fi
|
|
fi
|
|
# shellcheck disable=SC2064
|
|
trap "rm -rf '$runtime_dir'" EXIT
|
|
|
|
# --- invalid spec rejection + no artifact leak ------------------------
|
|
# Tests the negative-path create flow: a blatantly invalid VM spec
|
|
# must fail before any VM row is persisted. The review cycle flagged
|
|
# "cleanup on partial failure" as under-tested; this scenario pins
|
|
# that a rejected create doesn't leak a reservation we then have to
|
|
# clean up by hand.
|
|
log 'invalid spec rejection: --vcpu 0 must fail and leave no VM behind'
|
|
pre_vms="$("$BANGER" vm list --all 2>/dev/null | wc -l)"
|
|
set +e
|
|
"$BANGER" vm run --rm --vcpu 0 -- echo unused >/dev/null 2>&1
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -ne 0 ]] || die 'invalid spec: vm run succeeded despite --vcpu 0'
|
|
post_vms="$("$BANGER" vm list --all 2>/dev/null | wc -l)"
|
|
[[ "$pre_vms" == "$post_vms" ]] || die "invalid spec leaked a VM row: pre=$pre_vms, post=$post_vms"
|
|
|
|
# --- invalid name rejection ------------------------------------------
|
|
# VM names become DNS labels, guest hostnames, kernel-cmdline tokens
|
|
# and file-path fragments — the validator (ValidateVMName) must reject
|
|
# anything that isn't [a-z0-9-] with no leading/trailing hyphen and no
|
|
# dots. Smoke covers a few of the worst offenders end-to-end through
|
|
# the CLI; the full character-class matrix lives in
|
|
# internal/model/vm_name_test.go. Rejected names must also leave no
|
|
# VM row behind.
|
|
log 'invalid name rejection: uppercase / space / dot / leading-hyphen must all fail'
|
|
pre_vms="$("$BANGER" vm list --all 2>/dev/null | wc -l)"
|
|
for bad in 'MyBox' 'my box' 'box.vm' '-box'; do
|
|
set +e
|
|
"$BANGER" vm create --name "$bad" --no-start >/dev/null 2>&1
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -ne 0 ]] || die "invalid name: vm create accepted '$bad'"
|
|
done
|
|
post_vms="$("$BANGER" vm list --all 2>/dev/null | wc -l)"
|
|
[[ "$pre_vms" == "$post_vms" ]] \
|
|
|| die "invalid name leaked VM row(s): pre=$pre_vms, post=$post_vms"
|
|
|
|
# --- daemon stop (flushes coverage) -----------------------------------
|
|
log 'stopping daemon so instrumented binaries flush coverage'
|
|
"$BANGER" daemon stop >/dev/null 2>&1 || true
|
|
# Give the daemon a moment to write its covdata pod before the trap
|
|
# tears down runtime_dir.
|
|
sleep 0.5
|
|
|
|
log 'all scenarios passed'
|