VM stop has been quietly losing data freshly written via
`vm workspace prepare`: stop+start of a workspace-prepared VM would
come back with /root/repo wiped on the work disk.
Root cause is firecracker + Debian's systemd defaults. FC's
SendCtrlAltDel (the only "graceful shutdown" action FC exposes) just
delivers the keystroke; what the guest does with it is its choice.
Debian routes ctrl-alt-del.target -> reboot.target, so the guest
reboots, FC stays alive, the daemon's 10s wait_for_exit window
expires, and the SIGKILL fallback drops anything still in FC's
userspace I/O path. For an idle VM that's invisible. For one that
just took 100s of small writes through a workspace prepare, it's
data loss.
Fix is to dial the guest over SSH inside StopVM and run
`sync; systemctl --no-block poweroff || /sbin/poweroff -f &` before
the existing SendCtrlAltDel path. The synchronous `sync` is the
load-bearing piece — it blocks until every dirty page hits virtio-blk
and lands in the on-host root.ext4. Whether poweroff completes
before SIGKILL fires is incidental; sync has already run. SSH
unreachable falls back to the old SendCtrlAltDel behaviour so a
broken-network guest can't make stop hang.
Bounded by a 5s SSH-dial timeout so a half-broken guest can't extend
the overall stop window past gracefulShutdownWait.
Also adds two smoke scenarios:
- `workspace + stop/start`: prepare -> stop -> start -> assert
marker survives. This is the regression that caught the bug.
- `vm exec`: end-to-end coverage for d59425a — auto-cd into the
prepared workspace, exit-code propagation, dirty-host warning,
--auto-prepare resync, refusal on stopped VM.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
582 lines
27 KiB
Bash
582 lines
27 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# scripts/smoke.sh — end-to-end smoke suite for banger's supported
|
|
# two-service systemd model.
|
|
#
|
|
# Installs instrumented binaries as temporary bangerd.service +
|
|
# bangerd-root.service, drives real Firecracker/KVM scenarios, collects
|
|
# covdata from both services plus the CLI, then purges the smoke-owned
|
|
# install on exit.
|
|
#
|
|
# Because the supported path is global host state, smoke refuses to
|
|
# overwrite a pre-existing non-smoke install. If a prior smoke crashed,
|
|
# rerun `make smoke-clean` or `make smoke`; the smoke marker lets the
|
|
# harness purge only its own stale install safely.
|
|
#
|
|
# Scratch files live under $BANGER_SMOKE_XDG_DIR (historic name kept for
|
|
# make-compat). Service state uses the real supported system paths and is
|
|
# purged by the smoke cleanup path.
|
|
|
|
set -euo pipefail
|
|
|
|
log() { printf '[smoke] %s\n' "$*" >&2; }
|
|
die() { printf '[smoke] FAIL: %s\n' "$*" >&2; exit 1; }
|
|
|
|
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 smoke scratch 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
|
|
|
|
scratch_root="$BANGER_SMOKE_XDG_DIR"
|
|
runtime_dir=
|
|
smoke_owner="$(id -un)"
|
|
smoke_marker='/etc/banger/.smoke-owned'
|
|
service_cover_dir='/var/lib/banger'
|
|
owner_service='bangerd.service'
|
|
root_service='bangerd-root.service'
|
|
|
|
mkdir -p "$BANGER_SMOKE_COVER_DIR"
|
|
rm -rf "$scratch_root"
|
|
mkdir -p "$scratch_root"
|
|
runtime_dir="$(mktemp -d "$scratch_root/runtime-XXXXXX")"
|
|
|
|
# The CLI binary itself is instrumented, so keep its covdata local.
|
|
export GOCOVERDIR="$BANGER_SMOKE_COVER_DIR"
|
|
|
|
cleanup_export_vm() {
|
|
"$BANGER" vm delete smoke-export >/dev/null 2>&1 || true
|
|
}
|
|
|
|
cleanup_prune() {
|
|
"$BANGER" vm delete smoke-prune-running >/dev/null 2>&1 || true
|
|
"$BANGER" vm delete smoke-prune-stopped >/dev/null 2>&1 || true
|
|
}
|
|
|
|
collect_service_coverage() {
|
|
local uid gid
|
|
uid="$(id -u)"
|
|
gid="$(id -g)"
|
|
sudo bash -lc '
|
|
set -euo pipefail
|
|
shopt -s nullglob
|
|
dst="$1"
|
|
uid="$2"
|
|
gid="$3"
|
|
src="$4"
|
|
for file in "$src"/covmeta.* "$src"/covcounters.*; do
|
|
base="${file##*/}"
|
|
cp "$file" "$dst/$base"
|
|
chown "$uid:$gid" "$dst/$base"
|
|
chmod 0644 "$dst/$base"
|
|
done
|
|
' bash "$BANGER_SMOKE_COVER_DIR" "$uid" "$gid" "$service_cover_dir"
|
|
}
|
|
|
|
stop_services_for_coverage() {
|
|
sudo systemctl stop "$owner_service" "$root_service" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
sudo_banger() {
|
|
sudo env GOCOVERDIR="$BANGER_SMOKE_COVER_DIR" "$@"
|
|
}
|
|
|
|
cleanup() {
|
|
set +e
|
|
for vm in \
|
|
smoke-lifecycle smoke-set smoke-restart smoke-kill smoke-ports smoke-fc \
|
|
smoke-basecommit smoke-exec smoke-wsrestart smoke-nat smoke-nocnat; do
|
|
"$BANGER" vm delete "$vm" >/dev/null 2>&1 || true
|
|
done
|
|
cleanup_export_vm
|
|
cleanup_prune
|
|
stop_services_for_coverage
|
|
collect_service_coverage
|
|
sudo_banger "$BANGER" system uninstall --purge >/dev/null 2>&1 || true
|
|
rm -rf "$scratch_root"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
if sudo test -f /etc/banger/install.toml; then
|
|
if sudo test -f "$smoke_marker"; then
|
|
log 'found stale smoke-owned install; purging it first'
|
|
sudo_banger "$BANGER" system uninstall --purge >/dev/null 2>&1 || true
|
|
else
|
|
die 'banger is already installed on this host; supported-path smoke refuses to overwrite a non-smoke install'
|
|
fi
|
|
fi
|
|
|
|
log 'installing smoke-owned services'
|
|
sudo env \
|
|
GOCOVERDIR="$BANGER_SMOKE_COVER_DIR" \
|
|
BANGER_SYSTEM_GOCOVERDIR="$service_cover_dir" \
|
|
BANGER_ROOT_HELPER_GOCOVERDIR="$service_cover_dir" \
|
|
"$BANGER" system install --owner "$smoke_owner" >/dev/null \
|
|
|| die 'system install failed'
|
|
sudo touch "$smoke_marker"
|
|
|
|
status_out="$("$BANGER" system status)" || die 'system status failed after install'
|
|
grep -qE '^active +active' <<<"$status_out" || die "owner daemon not active after install: $status_out"
|
|
grep -qE '^helper_active +active' <<<"$status_out" || die "root helper not active after install: $status_out"
|
|
|
|
log 'doctor: checking host readiness'
|
|
if ! "$BANGER" doctor; then
|
|
die 'doctor reported failures; fix the host before running smoke'
|
|
fi
|
|
|
|
log 'system restart: services should come back cleanly'
|
|
sudo_banger "$BANGER" system restart >/dev/null || die 'system restart failed'
|
|
status_out="$("$BANGER" system status)" || die 'system status failed after restart'
|
|
grep -qE '^active +active' <<<"$status_out" || die "owner daemon not active after restart: $status_out"
|
|
grep -qE '^helper_active +active' <<<"$status_out" || die "root helper not active after restart: $status_out"
|
|
|
|
# --- 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 ------------------------------------
|
|
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) ----------------------------------------
|
|
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 -----------------------------------
|
|
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"
|
|
rm -f "$repodir/smoke-untracked.txt"
|
|
|
|
# --- workspace export round-trip --------------------------------------
|
|
log 'workspace export: create + prepare + guest edit + export + assert marker'
|
|
"$BANGER" vm create --name smoke-export --image debian-bookworm >/dev/null \
|
|
|| die "export: vm create exit $?"
|
|
"$BANGER" vm workspace prepare smoke-export "$repodir" >/dev/null \
|
|
|| die "export: workspace prepare exit $?"
|
|
"$BANGER" vm ssh smoke-export -- 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 smoke-export --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
|
|
|
|
# --- concurrent vm runs -----------------------------------------------
|
|
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) --------------------
|
|
log 'vm lifecycle: explicit create / stop / start / ssh / delete'
|
|
lifecycle_name=smoke-lifecycle
|
|
"$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"
|
|
|
|
# --- vm set reconfiguration (vcpu change + restart) -------------------
|
|
log 'vm set: create --vcpu 2 → stop → set --vcpu 4 → restart → nproc=4'
|
|
"$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'
|
|
|
|
# --- vm restart (dedicated verb) --------------------------------------
|
|
log 'vm restart: boot_id must change across the verb'
|
|
"$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'
|
|
|
|
# --- vm kill (--signal KILL, forceful path) ---------------------------
|
|
log 'vm kill --signal KILL: forceful terminate, state=stopped, no leaked dm device'
|
|
"$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'
|
|
|
|
# --- vm prune (-f) ----------------------------------------------------
|
|
log 'vm prune -f: removes stopped VMs, preserves running ones'
|
|
"$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'
|
|
|
|
# --- vm ports ---------------------------------------------------------
|
|
log 'vm ports: sshd :22 visible from host, endpoint uses the VM DNS name'
|
|
"$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'
|
|
|
|
# --- workspace prepare --mode full_copy -------------------------------
|
|
log 'workspace prepare --mode full_copy: alternate transfer path still delivers'
|
|
"$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'
|
|
|
|
# --- workspace export --base-commit (committed guest delta) -----------
|
|
log 'workspace export --base-commit: guest-side commits captured in patch'
|
|
"$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'
|
|
|
|
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"
|
|
|
|
"$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'
|
|
|
|
plain_patch="$runtime_dir/smoke-plain.diff"
|
|
"$BANGER" vm workspace export smoke-basecommit --output "$plain_patch" \
|
|
|| die 'export base: plain export failed'
|
|
if [[ -f "$plain_patch" ]] && grep -q 'smoke-committed.txt' "$plain_patch"; then
|
|
die 'export base: plain export unexpectedly captured the guest-side commit'
|
|
fi
|
|
|
|
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'
|
|
|
|
# --- workspace + stop/start lifecycle ---------------------------------
|
|
log 'workspace + stop/start: prepare → stop → start must yield a usable rootfs and preserve workspace contents'
|
|
"$BANGER" vm create --name smoke-wsrestart >/dev/null \
|
|
|| die 'workspace stop/start: create failed'
|
|
"$BANGER" vm workspace prepare smoke-wsrestart "$repodir" >/dev/null \
|
|
|| die 'workspace stop/start: prepare failed'
|
|
|
|
# Sanity: marker is present before the stop/start cycle.
|
|
pre_out="$("$BANGER" vm ssh smoke-wsrestart -- cat /root/repo/smoke-file.txt)" \
|
|
|| die 'workspace stop/start: pre-cycle ssh read failed'
|
|
grep -q 'smoke-workspace-marker' <<<"$pre_out" \
|
|
|| die "workspace stop/start: marker missing pre-cycle: $pre_out"
|
|
|
|
"$BANGER" vm stop smoke-wsrestart >/dev/null \
|
|
|| die 'workspace stop/start: stop failed'
|
|
"$BANGER" vm start smoke-wsrestart >/dev/null \
|
|
|| die 'workspace stop/start: start after stop failed (rootfs corrupt?)'
|
|
wait_for_ssh smoke-wsrestart \
|
|
|| die 'workspace stop/start: ssh did not come up after restart'
|
|
|
|
post_out="$("$BANGER" vm ssh smoke-wsrestart -- cat /root/repo/smoke-file.txt)" \
|
|
|| die 'workspace stop/start: post-cycle ssh read failed'
|
|
grep -q 'smoke-workspace-marker' <<<"$post_out" \
|
|
|| die "workspace stop/start: marker lost across stop/start: $post_out"
|
|
|
|
"$BANGER" vm delete smoke-wsrestart >/dev/null \
|
|
|| die 'workspace stop/start: delete failed'
|
|
|
|
# --- vm exec (workspace-aware, dirty detection, auto-prepare) ---------
|
|
log 'vm exec: cd into prepared workspace, exit-code propagation, stale-warn, --auto-prepare resync'
|
|
"$BANGER" vm create --name smoke-exec >/dev/null || die 'vm exec: create failed'
|
|
"$BANGER" vm workspace prepare smoke-exec "$repodir" >/dev/null \
|
|
|| die 'vm exec: workspace prepare failed'
|
|
|
|
# WORKSPACE column populated in vm show after prepare.
|
|
show_out="$("$BANGER" vm show smoke-exec)" || die 'vm exec: vm show after prepare failed'
|
|
grep -q '"guest_path": "/root/repo"' <<<"$show_out" \
|
|
|| die "vm exec: workspace.guest_path not persisted on VM record: $show_out"
|
|
|
|
# Basic happy path: cd happens, file is read from the workspace.
|
|
exec_cat="$("$BANGER" vm exec smoke-exec -- cat smoke-file.txt)" \
|
|
|| die "vm exec: cat smoke-file.txt failed"
|
|
grep -q 'smoke-workspace-marker' <<<"$exec_cat" \
|
|
|| die "vm exec: stdout missing workspace marker: $exec_cat"
|
|
|
|
# pwd confirms the auto-cd into the prepared guest path.
|
|
exec_pwd="$("$BANGER" vm exec smoke-exec -- pwd | tr -d '[:space:]')" \
|
|
|| die 'vm exec: pwd failed'
|
|
[[ "$exec_pwd" == "/root/repo" ]] \
|
|
|| die "vm exec: pwd got '$exec_pwd', want '/root/repo' (auto-cd didn't happen)"
|
|
|
|
# Exit-code propagation: 17 must come back as 17, verbatim.
|
|
set +e
|
|
"$BANGER" vm exec smoke-exec -- sh -c 'exit 17' >/dev/null 2>&1
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -eq 17 ]] || die "vm exec: exit-code propagation got rc=$rc, want 17"
|
|
|
|
# Dirty detection: advance host HEAD, run `vm exec` without --auto-prepare,
|
|
# expect a stale-workspace warning on stderr and the new file NOT present in
|
|
# the guest (workspace was not re-synced).
|
|
(
|
|
cd "$repodir"
|
|
echo 'post-prepare-marker' > smoke-exec-new.txt
|
|
git add smoke-exec-new.txt
|
|
git commit -q -m 'add smoke-exec-new.txt after prepare'
|
|
)
|
|
stale_stderr="$runtime_dir/smoke-exec-stale.err"
|
|
set +e
|
|
"$BANGER" vm exec smoke-exec -- ls smoke-exec-new.txt >/dev/null 2>"$stale_stderr"
|
|
ls_rc=$?
|
|
set -e
|
|
[[ "$ls_rc" -ne 0 ]] \
|
|
|| die 'vm exec: stale workspace unexpectedly already had the new file (dirty path didn'"'"'t take effect)'
|
|
grep -q 'workspace stale' "$stale_stderr" \
|
|
|| die "vm exec: stale-workspace warning missing on stderr; got: $(cat "$stale_stderr")"
|
|
grep -q -- '--auto-prepare' "$stale_stderr" \
|
|
|| die "vm exec: stale warning didn't mention --auto-prepare hint; got: $(cat "$stale_stderr")"
|
|
|
|
# --auto-prepare: re-syncs workspace, then runs the command. New file appears.
|
|
auto_out="$("$BANGER" vm exec smoke-exec --auto-prepare -- cat smoke-exec-new.txt)" \
|
|
|| die 'vm exec: --auto-prepare run failed'
|
|
grep -q 'post-prepare-marker' <<<"$auto_out" \
|
|
|| die "vm exec: --auto-prepare didn't re-sync new file; got: $auto_out"
|
|
|
|
# After auto-prepare, the warning must NOT reappear on the next exec —
|
|
# stored HEAD should now match the host.
|
|
clean_stderr="$runtime_dir/smoke-exec-clean.err"
|
|
"$BANGER" vm exec smoke-exec -- true 2>"$clean_stderr" \
|
|
|| die 'vm exec: post-auto-prepare exec failed'
|
|
if grep -q 'workspace stale' "$clean_stderr"; then
|
|
die "vm exec: stale warning persisted after --auto-prepare; got: $(cat "$clean_stderr")"
|
|
fi
|
|
|
|
# Reset repo state so later sections see the original tree.
|
|
(
|
|
cd "$repodir"
|
|
git reset --hard HEAD~1 -q
|
|
)
|
|
|
|
# Refusal when VM is not running: exec on a stopped VM must error out
|
|
# with a clear "not running" message. Done last so we can delete from
|
|
# the stopped state without needing a restart.
|
|
"$BANGER" vm stop smoke-exec >/dev/null || die 'vm exec: stop for not-running test failed'
|
|
set +e
|
|
stopped_err="$("$BANGER" vm exec smoke-exec -- true 2>&1)"
|
|
rc=$?
|
|
set -e
|
|
[[ "$rc" -ne 0 ]] || die 'vm exec: exec on stopped VM unexpectedly succeeded'
|
|
grep -q 'not running' <<<"$stopped_err" \
|
|
|| die "vm exec: stopped-VM error missing 'not running' phrase: $stopped_err"
|
|
|
|
"$BANGER" vm delete smoke-exec >/dev/null || die 'vm exec: delete failed'
|
|
|
|
# --- ssh-config install / uninstall (HOME-isolated) -------------------
|
|
log 'ssh-config --install / --uninstall: idempotent, survives round-trip'
|
|
fake_home="$scratch_root/fake-home"
|
|
mkdir -p "$fake_home/.ssh"
|
|
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 (!!)'
|
|
|
|
"$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) ------------------------
|
|
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
|
|
"$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
|
|
|
|
"$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"
|
|
|
|
"$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
|
|
|
|
# --- invalid spec rejection + no artifact leak ------------------------
|
|
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 ------------------------------------------
|
|
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"
|
|
|
|
log 'all scenarios passed'
|