banger/scripts/smoke.sh
Thales Maciel 71a332a6a1
cli: maturity polish — color, error translation, tabwriter consistency
Adds three small but high-leverage presentation tweaks for v0.1:

1. internal/cli/style is a new ~70 LOC package with Pass/Fail/Warn/
   Dim/Bold helpers. Each is TTY-gated and obeys NO_COLOR. No
   external dep. Wired into the doctor PASS/FAIL/WARN status, the
   "banger:" error prefix on stderr, and the dim 'ready in <elapsed>'
   line.
2. internal/cli/errors translates rpc.ErrorResponse into user-facing
   text. operation_failed becomes invisible (the message wins);
   not_found, already_exists, bad_request, bad_version, unauthorized,
   unknown_method get short labels; unknown codes pass through. The
   daemon-attached op_id lands in dim parens — paste into
   journalctl --grep to find the daemon log line that produced the
   failure.
3. Tabwriter config converges on (0, 8, 2, ' ', 0) across every
   list/table command. The vm prune confirmation table picked up the
   right config; system install + system status switched from bare
   "key: value\n" lines to tabular form. printVMSpecLine drops its
   Unicode middle dot for an ASCII '|' so terminals without UTF-8
   render cleanly.

Tests cover translateRPCError for every code, style helpers no-op
on non-TTY and under NO_COLOR. Smoke status greps switch from
"key: value" to "key   value" to match the new format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 22:27:07 -03:00

468 lines
22 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-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'
# --- 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'