go-firewall/test/integration/host-freebsd-vm.sh
2026-07-08 15:54:48 -05:00

196 lines
7.1 KiB
Bash
Executable file

#!/usr/bin/env bash
# HOST script — run this on your workstation. It boots a throwaway FreeBSD QEMU VM
# and runs the pf integration backend inside it.
#
# FreeBSD's pf runs natively, so the VM *is* the test environment: it
# enables pf with a minimal ruleset, mounts the freebsd-cross-compiled test binary
# from the host over a virtio-9p share, and runs it with FIREWALL_BACKEND=pf. The
# same pf backend serves macOS, which cannot be automated in a VM.
#
# Usage:
# ./host-freebsd-vm.sh
#
# The overlay disk is kept between runs by default. pf state lives in the kernel and
# is cleared on every reboot (reloaded from /etc/pf.conf), so reuse just needs a
# fresh cloud-init seed with a unique instance-id each run so cloud-init executes
# the test payload on every boot. Set GOFW_VM_REUSE=0 to force a fresh overlay each
# run; deleting .cache/freebsd-overlay.qcow2 also resets it.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
IMAGE_URL="${GOFW_FBSD_IMAGE_URL:-https://download.freebsd.org/releases/VM-IMAGES/15.1-RELEASE/amd64/Latest/FreeBSD-15.1-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz}"
MEM="${GOFW_VM_MEM:-2048}"
CPUS="${GOFW_VM_CPUS:-2}"
CACHE="${GOFW_VM_CACHE:-$repo/.cache}"
REUSE="${GOFW_VM_REUSE:-1}" # 1 = keep the overlay disk between runs (default)
BOOT_TIMEOUT="${GOFW_VM_TIMEOUT:-1200}"
SHUTDOWN_GRACE="${GOFW_VM_SHUTDOWN_GRACE:-60}" # seconds to wait for a clean poweroff before killing
mkdir -p "$CACHE"
base="$CACHE/freebsd.qcow2"
overlay="$CACHE/freebsd-overlay.qcow2"
seed="$CACHE/freebsd-seed.iso"
console="$CACHE/freebsd-console.log"
bin="$CACHE/firewall.test.freebsd"
command -v qemu-system-x86_64 >/dev/null || { echo "!! qemu-system-x86_64 not found"; exit 1; }
command -v genisoimage >/dev/null || { echo "!! genisoimage not found"; exit 1; }
echo ">> cross-compiling freebsd test binary on host"
( cd "$repo" && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go test -c -tags integration -o "$bin" . )
if [[ ! -f "$base" ]]; then
echo ">> downloading FreeBSD cloud image -> $base"
curl -L --fail -o "$base.xz" "$IMAGE_URL"
xz -dc "$base.xz" >"$base"
rm -f "$base.xz"
fi
# Reuse the overlay only when asked and one already exists; otherwise start fresh.
if [[ "$REUSE" = 1 ]] && [[ -f "$overlay" ]]; then
echo ">> reusing existing overlay disk (GOFW_VM_REUSE=1): $overlay"
else
echo ">> creating a fresh overlay disk (base stays pristine)"
rm -f "$overlay"
qemu-img create -f qcow2 -b "$base" -F qcow2 "$overlay" 20G >/dev/null
fi
# A fresh cloud-init seed is generated for every run. The unique instance-id makes
# each boot look like a new instance to cloud-init, so per-instance modules
# (write_files and runcmd) execute on both the first and every reused boot.
run_id="$(date +%s)-${RANDOM}"
instance_id="gofw-it-fbsd-${run_id}"
work="$(mktemp -d "$CACHE/fbsd-seed.XXXXXX")"
trap 'rm -rf "$work"' EXIT
cat >"$work/meta-data" <<EOF
instance-id: ${instance_id}
local-hostname: gofw-it-fbsd
EOF
cat >"$work/user-data" <<EOF
#cloud-config
bootcmd:
# Skip the FreeBSD cloud image's first-boot freebsd-update/pkg fetch — it adds
# minutes of patch downloads and is irrelevant to the test. Runs before the
# firstboot rc scripts (a fresh overlay re-triggers them every run otherwise).
- [ rm, -f, /firstboot ]
write_files:
- path: /etc/pf.conf
content: |
set skip on lo0
runcmd:
- sysrc pf_enable=YES
- service pf start
- sh -c 'kldload virtio_p9fs 2>/dev/null || true'
- mkdir -p /mnt/gofw-cache
- mount -t p9fs gofwcache /mnt/gofw-cache
- cp /mnt/gofw-cache/firewall.test.freebsd /root/firewall.test
- chmod +x /root/firewall.test
- sh -c 'echo GOFW_VM_BEGIN; env FIREWALL_BACKEND=pf /root/firewall.test -test.v -test.run TestIntegration; echo "GOFW_VM_DONE rc=\$?"'
- poweroff
EOF
genisoimage -quiet -output "$seed" -volid CIDATA -joliet -rock \
"$work/user-data" "$work/meta-data"
echo ">> booting FreeBSD VM (serial console below; it powers off when finished)"
echo " Ctrl-C requests a clean ACPI shutdown (force-killed after ${SHUTDOWN_GRACE}s)."
echo " ------------------------------------------------------------------"
: >"$console"
# HMP monitor on a localhost-only, transient TCP port so the interrupt/timeout
# handlers can ask the guest to power off cleanly, poked via bash /dev/tcp.
mon_port=$(( 20000 + RANDOM % 20000 ))
# Stream serial through a FIFO so we hold qemu's real PID and tee flushes the log
# deterministically before we read it.
fifo="$work/qemu.out"
mkfifo "$fifo"
tee "$console" <"$fifo" &
tee_pid=$!
qemu_pid=""
interrupted=0
timed_out=0
# stop_vm asks the guest for a clean ACPI poweroff, waits SHUTDOWN_GRACE seconds,
# then hard-kills if it overruns. Idempotent; safe to call more than once.
stop_vm() {
kill -0 "$qemu_pid" 2>/dev/null || return 0
if exec 3<>"/dev/tcp/127.0.0.1/$mon_port" 2>/dev/null; then
printf 'system_powerdown\n' >&3
exec 3<&- 3>&-
fi
for _ in $(seq 1 "$SHUTDOWN_GRACE"); do
kill -0 "$qemu_pid" 2>/dev/null || return 0
sleep 1
done
echo "!! VM did not power off within ${SHUTDOWN_GRACE}s; killing it" >&2
kill -9 "$qemu_pid" 2>/dev/null || true
}
# on_interrupt drives a clean shutdown on Ctrl-C; further signals are ignored so a
# second Ctrl-C cannot abort mid-shutdown and orphan qemu. Invoked via the trap below.
# shellcheck disable=SC2329 # invoked indirectly by `trap on_interrupt INT TERM`.
on_interrupt() {
trap '' INT TERM
interrupted=1
echo >&2
echo ">> interrupt received; asking the VM to power off cleanly…" >&2
stop_vm
}
trap on_interrupt INT TERM
set +e
qemu-system-x86_64 \
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
-drive file="$overlay",if=virtio,format=qcow2 \
-drive file="$seed",if=virtio,format=raw \
-netdev user,id=n0 -device virtio-net-pci,netdev=n0 \
-fsdev local,id=fs0,path="$CACHE",security_model=none,readonly=on \
-device virtio-9p-pci,fsdev=fs0,mount_tag=gofwcache \
-monitor tcp:127.0.0.1:"$mon_port",server,nowait \
-serial stdio -display none -no-reboot </dev/null >"$fifo" 2>&1 &
qemu_pid=$!
# Wait for the VM, enforcing the boot timeout, while staying interruptible.
deadline=$(( SECONDS + BOOT_TIMEOUT ))
while kill -0 "$qemu_pid" 2>/dev/null; do
if [[ "$SECONDS" -ge "$deadline" ]]; then
timed_out=1
echo >&2
echo "!! VM exceeded ${BOOT_TIMEOUT}s; shutting it down" >&2
stop_vm
break
fi
sleep 2 & wait $! 2>/dev/null
done
wait "$qemu_pid" 2>/dev/null
trap - INT TERM
wait "$tee_pid" 2>/dev/null # let tee drain the FIFO and flush the console log
set -e
echo " ------------------------------------------------------------------"
if [[ "$interrupted" = 1 ]]; then
echo "!! run interrupted; VM stopped. Partial serial log: $console"
exit 130
fi
if [[ "$timed_out" = 1 ]]; then
echo "!! VM timed out after ${BOOT_TIMEOUT}s (see $console)"
exit 124
fi
echo
echo "==== FreeBSD pf run summary ===="
if grep -q "GOFW_VM_DONE" "$console"; then
grep -E -- '--- (PASS|FAIL|SKIP): |^(PASS|FAIL|ok)\b|GOFW_VM_DONE' "$console" | sed 's/\r$//'
rc="$(grep -o 'GOFW_VM_DONE rc=[0-9]*' "$console" | tail -1 | grep -o '[0-9]*$')"
echo "(full serial log: $console)"
exit "${rc:-1}"
fi
echo "!! the in-VM run did not complete (no GOFW_VM_DONE marker). See $console"
exit 1