kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

commit 3e9ce3177b318a8cf1e867589ab9f7a4628715fc
parent f2fa4890b0f5a8f2789856784b845889b4692c18
Author: Ryan Sepassi <rsepassi@gmail.com>
Date:   Thu,  4 Jun 2026 13:57:12 -0700

freebsd: VM execution harness for amd64/aarch64/rv64

scripts/freebsd_vm.sh provisions and runs FreeBSD VMs as kit execution
targets, plus doc/plan/FREEBSD.md.

- Provision once, cache a compressed "golden" disk in the download cache
  (survives `make clean`); restore in seconds thereafter.
- amd64: reset EDK2 NVRAM vars each run (else the firmware boots its UEFI
  Shell instead of the disk -- the "hang") and -vga none so the serial
  console is primary.
- aarch64: virt + HVF (fast).
- rv64: -machine virt,acpi=off (FreeBSD/riscv64 is FDT-only) plus an
  expect-driven serial bootstrap, since FreeBSD publishes no cloud-init
  riscv64 image.
- cloud-init arches provision offline (restrict=on) so the first-boot
  freebsd-update fast-fails instead of running a slow networked update
  and forcing a reboot.

All three boot and are SSH-reachable as the `kit` user.

Diffstat:
Adoc/plan/FREEBSD.md | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/freebsd_vm.sh | 652+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 855 insertions(+), 0 deletions(-)

diff --git a/doc/plan/FREEBSD.md b/doc/plan/FREEBSD.md @@ -0,0 +1,203 @@ +# FreeBSD target support + +Status and roadmap for compiling, linking, and running FreeBSD binaries with +kit, plus the QEMU VM harness used to execute them. Scope is the release support +set: **arm64 (aarch64), x64 (amd64), rv64 (riscv64)** on FreeBSD. + +The execution environment (the VMs) is in good shape; the **compile/link path is +the gating work** — FreeBSD cross-compile was previously untested and turns out +to need several fixes, most already landed, with one real linker blocker left. + +## TL;DR + +- VM harness: `scripts/freebsd_vm.sh`. amd64 + aarch64 + rv64 all boot and are + SSH-reachable as durable, cached "golden" disks. This is the way to execute + FreeBSD binaries on all three arches. +- Compile path: target parsing, runtime variants, COMDAT-group reading, and the + FreeBSD-15 `libsys` split are handled. **Remaining blocker:** the libc/libsys + weak-alias archive cycle (`undefined reference to 'openat'`). + +## Execution environment — `scripts/freebsd_vm.sh` + +Orchestrated from a macOS/arm64 host with Homebrew QEMU. One command set provides +download, provision, run, and SSH for each arch. + +### Provisioning model (provision once, cache forever) + +A first boot does the expensive one-time setup; the result is saved as a +compressed **golden disk in the download cache** (`$DL_ROOT`, which survives +`make clean`). Later `prepare`/`run` restore it in seconds. + +- `prepare <arch>` → `ensure_disk`: use an existing provisioned disk, else + restore the cached golden disk, else expand the pristine image + provision + + cache. +- `run <arch>` boots the golden disk with full networking and host-only SSH + forwarding. +- `provision <arch>` forces a (re)provision. + +Two provisioning paths: + +- **Cloud-init arches (amd64, aarch64):** `provision_nuageinit` boots the + BASIC-CLOUDINIT image **offline** (`-netdev user,restrict=on`). nuageinit + (FreeBSD's native cloud-init) creates the `kit` user, imports the SSH key, and + enables sshd; the `firstboot_freebsd_update` service fails fast for lack of a + route ("mirrors... none found") instead of running a slow networked update and + forcing a reboot. The script then SSHes a clean `shutdown -p now` and caches. +- **rv64 (no cloud-init image published):** `provision_serial` drives the serial + console with `expect`: log in as root, `pw useradd kit`, install the key, + `sysrc sshd_enable=YES` + `ifconfig_vtnet0=DHCP`, `service sshd keygen`, power + off. Same golden-cache result. + +### Per-arch boot quirks (all fixed in the script) + +- **amd64**: runs under **TCG** (no hardware accel on an arm64 host). Two traps, + both fixed: + - The persisted EDK2 NVRAM vars drift so the firmware boots its built-in + **UEFI Shell** instead of the disk — looks exactly like a hang (CPU idle at + `Shell>`). `ensure_firmware_vars` now resets the vars from the pristine + template every run. + - q35 makes the headless VGA the primary console, so all userland/cloud-init + output and the login getty are invisible. amd64 runs `-vga none` → serial is + primary. +- **aarch64**: `-machine virt`, **HVF-accelerated** (fast); serial is already the + primary console. The reference path. +- **rv64**: runs under TCG. Boots via OpenSBI → edk2-riscv (UEFI) → FreeBSD + loader. Must pass **`-machine virt,acpi=off`**: FreeBSD/riscv64 is FDT-only, + and with ACPI advertised the loader reports "no valid device tree blob" and the + kernel boots blind. With `acpi=off` it gets `ofwbus0 <Open Firmware Device + Tree>` and mounts root normally. + +### Knobs + +`KIT_FREEBSD_RELEASE` (default 15.0-RELEASE), `KIT_FREEBSD_MEM`, +`KIT_FREEBSD_CPUS`, `KIT_FREEBSD_PROVISION_TIMEOUT`, `KIT_FREEBSD_ACCEL`, +`KIT_FREEBSD_SSH_USER`, `KIT_FREEBSD_SSH_KEY`. See `freebsd_vm.sh doctor`. + +### Sysroot extraction + +There is no committed FreeBSD sysroot; extract one per arch from a running VM +(it is the matching base system). Minimal static-link set: + +``` +bash scripts/freebsd_vm.sh ssh <arch> \ + 'tar cf - -C / usr/include usr/lib/crt1.o usr/lib/crti.o usr/lib/crtn.o \ + usr/lib/libc.a usr/lib/libsys.a usr/lib/libssp_nonshared.a' \ + | tar xf - -C build/freebsd-sysroot/<arch> +``` + +A proper `base.txz` extraction harness paralleling `test/libc/{glibc,musl}` is the +intended durable mechanism (pin the dist, record the version). + +## Compile / link path + +`kit cc --target=<arch>-freebsdN.N --sysroot=<root> [-static] file.c`. Driven +through the hosted FreeBSD profile in `driver/lib/hosted.c` +(`hosted_resolve_freebsd`): crt1/Scrt1 + crti + crtn, `libc.a` (static) or +`libc.so.7` (dynamic), interp `/libexec/ld-elf.so.1`, and `__FreeBSD__`/`__ELF__` +defines. + +### Fixed + +- [x] **Triple parsing** (`driver/lib/target.c`). `freebsd` was not recognized at + all — `--target=*-freebsd` silently fell through to *freestanding*. Added + prefix matching so versioned tokens (`freebsd15.0`, `freebsd14`) parse to + `KIT_OS_FREEBSD` / ELF. +- [x] **Runtime variants** (`driver/lib/runtime.c`). Added + `{x86_64,aarch64,riscv64}-freebsd` to `kRtVariants` (ELF, mirroring the + Linux ELF runtime). Without them, `cc` aborts with "compiler runtime is not + available for this target". The driver builds the archive on demand. +- [x] **ELF COMDAT groups on read** (`src/obj/elf/read.c`, `elf.h`). FreeBSD's + `crt1.o` brands the binary by placing its `.note.tag` ABI note in an ELF + COMDAT group whose signature symbol is `.freebsd.note*`. kit consumes the + `SHT_GROUP` section into an `ObjGroup` and never keeps it as a real + section, which orphaned the signature symbol into a phantom "undefined + reference". Fixed: a symbol defined in an `SHT_GROUP` section is recorded as + an absolute defined symbol (it names a group, is never a reloc target). +- [x] **FreeBSD 15 `libsys` split** (`driver/lib/hosted.c`). FreeBSD 15 moved the + raw syscall stubs out of libc into `libsys`. The hosted profile now links + `libsys.{a,so.7}` after libc when the sysroot provides it (pre-15 roots + lack it, so it is conditional). +- [x] **`STB_GNU_UNIQUE` binding** (`src/obj/elf/read.c`). Tangential but real: + GNU-unique was mapped to `SB_LOCAL`, hiding such globals from cross-object + resolution. Now mapped to global. + +### Blocker — libc/libsys weak-alias archive cycle + +`-static` links currently fail with `undefined reference to 'openat'` (and would +hit the same class for other syscall wrappers). + +Root cause: FreeBSD 15 splits the syscall path across two mutually-recursive +archives with weak aliases: + +- `openat` (public) is a **weak alias** → `_openat` → `__sys_openat`. +- `libc.a` references `__sys_openat`; `libsys.a` provides `__sys_openat` and + references back into libc; `openat`/`_openat` live in `libc.a`. + +kit's `link_ingest_archives` (`src/link/link_resolve.c`) resolves each archive to +a fixpoint, but only against the symbols **defined before it in link order** — it +does not re-scan an earlier archive when a later one introduces new undefined +references. So a back-reference from `libsys.a` into `libc.a` is not satisfied. +Re-listing `libc.a` after `libsys.a` (the `--start-group` idiom; currently in the +hosted profile) did **not** resolve it on its own, so weak-definition archive-pull +semantics are likely also involved and need confirmation. + +Proposed fix (linker work, regression-sensitive — guard with the existing +`test-link` corpus): + +- [ ] Add archive **group** semantics: re-scan a set of archives to a fixpoint so + cross-archive cycles resolve (`--start-group`/`--end-group`, or a global + whole-set fixpoint for the hosted-profile archives). +- [ ] Confirm/fix weak-definition archive-pull: a strong undefined reference must + pull an archive member that defines the symbol **weakly** (the `openat` + alias case). Verify against `test/link` archive-demand cases. +- [ ] Re-validate musl/glibc static links (`hosted_resolve_linux_*`) — they share + the same ingestion path. + +### Other known gaps + +- [ ] **`__FreeBSD__` version** is hardcoded to `14` in + `hosted_add_freebsd_defines`; the VMs are 15.0. Now that the triple carries + the version (`triple_tok_prefix`), thread an OS-version field through + `KitTargetSpec` and derive it. +- [ ] **Dynamic links** are unvalidated: PT_INTERP `/libexec/ld-elf.so.1`, + `libc.so.7` direct binding (note `/usr/lib/libc.so` is a GNU ld linker + script `GROUP(...)` kit cannot parse — the profile already binds + `libc.so.7` directly), and `DT_NEEDED` resolution. +- [ ] **Run validation**: once a binary links, confirm the FreeBSD kernel accepts + and runs it (ABI brand note, page-zero, stack setup) by executing it in the + VM over SSH. Not yet reached. +- [ ] **`kit` running on FreeBSD**: `driver/env/freebsd.c` is written from man + pages and marked UNTESTED (memfd/execmem dual-map, sysctl, resolver). The + VMs now make native verification possible. + +## Validation matrix (per arch) + +For each of aarch64 / x64 / rv64 on FreeBSD: + +- [ ] `kit cc -c` produces a valid object with correct predefined macros / data + model. +- [ ] `kit cc -static` links a hosted executable against a real base sysroot. +- [ ] `kit cc` (dynamic) links against `libc.so.7` with correct PT_INTERP / + DT_NEEDED. +- [ ] The produced binary runs in the VM and returns the expected output. +- [ ] Runtime helpers, atomics, setjmp, coroutines pass targeted tests. + +## How to reproduce today + +```sh +# 1. Provision the VMs (one-time, cached afterwards). amd64/rv64 are slow (TCG). +bash scripts/freebsd_vm.sh prepare aarch64 +bash scripts/freebsd_vm.sh prepare amd64 +bash scripts/freebsd_vm.sh prepare riscv64 + +# 2. Extract a sysroot from one (see "Sysroot extraction" above). + +# 3. Cross-compile (currently fails at the libc/libsys link blocker): +build/kit cc --target=x86_64-freebsd15.0 \ + --sysroot=build/freebsd-sysroot/amd64 -static hello.c -o hello + +# 4. Once linking works: run it on the VM. +bash scripts/freebsd_vm.sh run amd64 & # boot +scp -i build/freebsd-vm/ssh/id_ed25519 -P 2222 hello kit@127.0.0.1: +bash scripts/freebsd_vm.sh ssh amd64 ./hello +``` diff --git a/scripts/freebsd_vm.sh b/scripts/freebsd_vm.sh @@ -0,0 +1,652 @@ +#!/usr/bin/env bash +# Prepare and run FreeBSD release VMs used as execution targets for kit tests. +# +# Defaults target FreeBSD 15.0-RELEASE VM images: +# amd64 -> BASIC-CLOUDINIT UFS qcow2 +# aarch64 -> BASIC-CLOUDINIT UFS qcow2 +# riscv64 -> UFS qcow2 (no BASIC-CLOUDINIT image is published) +# +# Downloaded .xz images live under XDG cache by default so `make clean` does +# not remove them. Expanded VM disks, firmware vars, and seed ISOs live under +# build/freebsd-vm/ by default. + +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VM_ROOT="${KIT_FREEBSD_VM_DIR:-$ROOT/build/freebsd-vm}" +RELEASE="${KIT_FREEBSD_RELEASE:-15.0-RELEASE}" +XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +DL_ROOT="${KIT_FREEBSD_DOWNLOAD_DIR:-$XDG_CACHE_HOME/kit/freebsd-vm/$RELEASE}" +BASE_URL="${KIT_FREEBSD_BASE_URL:-https://download.freebsd.org/releases/VM-IMAGES/$RELEASE}" +QEMU_SHARE="${KIT_QEMU_SHARE:-/opt/homebrew/share/qemu}" +SSH_USER="${KIT_FREEBSD_SSH_USER:-kit}" +SSH_KEY="${KIT_FREEBSD_SSH_KEY:-$VM_ROOT/ssh/id_ed25519}" + +usage() { + cat <<EOF +usage: scripts/freebsd_vm.sh <command> <arch> [args...] + +commands: + doctor print local QEMU/image tool availability + fetch <arch> download image and checksum + prepare <arch> restore/build a provisioned, ready-to-run disk + seed <arch> create a NoCloud cidata ISO for cloud-init images + provision <arch> boot once offline to create user+sshd, cache golden disk + run <arch> [qemu...] run VM in foreground with host-only SSH forwarding + wait-ssh <arch> wait for SSH and print uname + ssh <arch> [cmd...] SSH into a running VM + +arches: + amd64 | x64 | aarch64 | arm64 | rv64 | riscv64 + +env: + KIT_FREEBSD_VM_DIR artifact directory (default: build/freebsd-vm) + KIT_FREEBSD_DOWNLOAD_DIR + downloaded .xz/checksum cache + (default: \${XDG_CACHE_HOME:-\$HOME/.cache}/kit/freebsd-vm/<release>) + KIT_FREEBSD_RELEASE FreeBSD release (default: 15.0-RELEASE) + KIT_FREEBSD_MEM QEMU memory in MiB (default: 2048) + KIT_FREEBSD_CPUS QEMU CPU count (default: 2) + KIT_FREEBSD_PROVISION_TIMEOUT + seconds to wait for the offline provisioning boot to + reach a login prompt (default: 1800) + KIT_FREEBSD_ACCEL QEMU accel override (default: hvf for aarch64 on arm64 macOS) + KIT_FREEBSD_SSH_USER cloud-init user (default: kit) + KIT_FREEBSD_SSH_KEY private SSH key path +EOF +} + +die() { + printf 'freebsd-vm: %s\n' "$*" >&2 + exit 1 +} + +canon_arch() { + case "${1:-}" in + amd64|x64|x86_64) echo amd64 ;; + aarch64|arm64|aa64) echo aarch64 ;; + riscv64|rv64) echo riscv64 ;; + *) die "unknown arch '${1:-}'" ;; + esac +} + +arch_dir() { + case "$(canon_arch "$1")" in + amd64) echo amd64 ;; + aarch64) echo aarch64 ;; + riscv64) echo riscv64 ;; + esac +} + +image_file() { + case "$(canon_arch "$1")" in + amd64) echo "FreeBSD-$RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz" ;; + aarch64) echo "FreeBSD-$RELEASE-arm64-aarch64-BASIC-CLOUDINIT-ufs.qcow2.xz" ;; + riscv64) echo "FreeBSD-$RELEASE-riscv-riscv64-ufs.qcow2.xz" ;; + esac +} + +cloudinit_supported() { + case "$(canon_arch "$1")" in + amd64|aarch64) return 0 ;; + riscv64) return 1 ;; + esac +} + +ssh_port() { + case "$(canon_arch "$1")" in + amd64) echo "${KIT_FREEBSD_AMD64_SSH_PORT:-2222}" ;; + aarch64) echo "${KIT_FREEBSD_AARCH64_SSH_PORT:-2223}" ;; + riscv64) echo "${KIT_FREEBSD_RISCV64_SSH_PORT:-2224}" ;; + esac +} + +qemu_bin() { + case "$(canon_arch "$1")" in + amd64) echo qemu-system-x86_64 ;; + aarch64) echo qemu-system-aarch64 ;; + riscv64) echo qemu-system-riscv64 ;; + esac +} + +disk_path() { + echo "$VM_ROOT/images/freebsd-$(canon_arch "$1").qcow2" +} + +seed_path() { + echo "$VM_ROOT/seeds/freebsd-$(canon_arch "$1")-seed.iso" +} + +checksum_path() { + echo "$DL_ROOT/$(canon_arch "$1")-CHECKSUM.SHA256" +} + +download_path() { + echo "$DL_ROOT/$(image_file "$1")" +} + +host_is_darwin_arm64() { + [ "$(uname -s 2>/dev/null)" = "Darwin" ] && + { [ "$(uname -m 2>/dev/null)" = "arm64" ] || + [ "$(uname -m 2>/dev/null)" = "aarch64" ]; } +} + +qemu_accel_args() { + local arch="$1" accel="${KIT_FREEBSD_ACCEL:-}" + if [ -z "$accel" ] && [ "$(canon_arch "$arch")" = "aarch64" ] && + host_is_darwin_arm64; then + accel=hvf + fi + [ -n "$accel" ] && printf '%s\n%s\n' -accel "$accel" +} + +firmware_code() { + case "$(canon_arch "$1")" in + amd64) echo "$QEMU_SHARE/edk2-x86_64-code.fd" ;; + aarch64) echo "$QEMU_SHARE/edk2-aarch64-code.fd" ;; + riscv64) echo "$QEMU_SHARE/edk2-riscv-code.fd" ;; + esac +} + +firmware_vars_template() { + case "$(canon_arch "$1")" in + amd64) echo "$QEMU_SHARE/edk2-i386-vars.fd" ;; + aarch64) echo "$QEMU_SHARE/edk2-arm-vars.fd" ;; + riscv64) echo "$QEMU_SHARE/edk2-riscv-vars.fd" ;; + esac +} + +firmware_vars() { + echo "$VM_ROOT/firmware/$(canon_arch "$1")-vars.fd" +} + +ensure_ssh_key() { + if [ ! -f "$SSH_KEY" ]; then + mkdir -p "$(dirname "$SSH_KEY")" + ssh-keygen -q -t ed25519 -N "" -f "$SSH_KEY" + fi +} + +fetch_arch() { + local arch="$1" dir file url cksum + arch="$(canon_arch "$arch")" + dir="$(arch_dir "$arch")" + file="$(image_file "$arch")" + url="$BASE_URL/$dir/Latest/$file" + mkdir -p "$DL_ROOT" + printf 'fetch checksum: %s\n' "$BASE_URL/$dir/Latest/CHECKSUM.SHA256" + curl -fL --retry 3 --retry-delay 2 -o "$(checksum_path "$arch")" \ + "$BASE_URL/$dir/Latest/CHECKSUM.SHA256" + printf 'fetch image: %s\n' "$url" + curl -fL --continue-at - --retry 3 --retry-delay 2 \ + -o "$(download_path "$arch")" "$url" + cksum="$(awk -v f="$file" '$2 == "(" f ")" && $3 == "=" { print $4 }' \ + "$(checksum_path "$arch")")" + [ -n "$cksum" ] || die "checksum entry missing for $file" + verify_sha256 "$arch" "$cksum" +} + +verify_sha256() { + local arch="$1" expect="$2" got + got="$(shasum -a 256 "$(download_path "$arch")" | awk '{ print $1 }')" + [ "$got" = "$expect" ] || + die "checksum mismatch for $(image_file "$arch"): got $got want $expect" + printf 'verified: %s\n' "$(image_file "$arch")" +} + +# Produce a ready-to-run, provisioned disk: restore the cached golden disk if we +# have one, otherwise download/expand the pristine image and provision it. The +# heavy lifting lives in ensure_disk (defined below). +prepare_arch() { + ensure_disk "$1" +} + +seed_arch() { + local arch="$1" seed_dir seed key + arch="$(canon_arch "$arch")" + cloudinit_supported "$arch" || + die "$arch does not have a FreeBSD BASIC-CLOUDINIT VM image" + ensure_ssh_key + mkdir -p "$VM_ROOT/seeds" + seed_dir="$VM_ROOT/seeds/$(canon_arch "$arch")" + seed="$(seed_path "$arch")" + key="$(cat "$SSH_KEY.pub")" + rm -rf "$seed_dir" + mkdir -p "$seed_dir" + cat > "$seed_dir/meta-data" <<EOF +local-hostname: kit-freebsd-$arch +EOF + # nuageinit (FreeBSD's native cloud-init) processes this #cloud-config. + # runcmd only re-asserts sshd_enable (idempotent): sshd is already enabled in + # the image rc.conf, so the base rc starts it exactly once. We deliberately do + # NOT "service sshd start" here -- that races the base rc start and dies with + # "Bind to port 22 ... Address already in use". + cat > "$seed_dir/user-data" <<EOF +#cloud-config +users: + - name: $SSH_USER + gecos: Kit FreeBSD Test User + groups: wheel + homedir: /home/$SSH_USER + shell: /bin/sh + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + - "$key" +ssh_authorized_keys: + - "$key" +network: + version: 2 + ethernets: + vtnet0: + dhcp4: true +runcmd: + - sysrc sshd_enable=YES +EOF + rm -f "$seed" + hdiutil makehybrid -quiet -iso -joliet -default-volume-name cidata \ + -o "$seed" "$seed_dir" + printf 'seed ready: %s\n' "$seed" +} + +ensure_firmware_vars() { + local arch="$1" code vars tmpl + code="$(firmware_code "$arch")" + vars="$(firmware_vars "$arch")" + tmpl="$(firmware_vars_template "$arch")" + [ -f "$code" ] || die "missing QEMU firmware code: $code" + [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl" + mkdir -p "$(dirname "$vars")" + # Always start from the pristine template. The EDK2 firmware persists its + # boot order / NVRAM into this vars file; once it drifts (e.g. after a run + # with a different device set) it can boot the built-in UEFI Shell instead + # of chainloading the disk, which looks exactly like a hang. Resetting per + # run keeps the boot path deterministic; nothing here relies on NVRAM + # persisting across runs. + cp "$tmpl" "$vars" +} + +append_firmware_args() { + local arch="$1" code vars + ensure_firmware_vars "$arch" + code="$(firmware_code "$arch")" + vars="$(firmware_vars "$arch")" + QEMU_ARGS=("${QEMU_ARGS[@]}" + -drive "if=pflash,format=raw,readonly=on,file=$code" + -drive "if=pflash,format=raw,file=$vars") +} + +# append_common_args ARCH DISK PORT [RESTRICT] [SERIALSPEC] +# RESTRICT non-empty -> isolate the guest from the outside network. DHCP and +# the SSH hostfwd still work, but outbound connections fail fast. Used while +# provisioning so firstboot_freebsd_update cannot run its slow networked +# update (and force a reboot) on the first boot. +# SERIALSPEC defaults to "mon:stdio" (interactive foreground). Provisioning +# passes "file:<log>" so a backgrounded boot can be watched. +append_common_args() { + local arch="$1" disk="$2" port="$3" restrict="${4:-}" serialspec="${5:-mon:stdio}" + local mem="${KIT_FREEBSD_MEM:-2048}" cpus="${KIT_FREEBSD_CPUS:-2}" + local netopt="user,id=net0,hostfwd=tcp:127.0.0.1:$port-:22" + [ -n "$restrict" ] && netopt="user,id=net0,restrict=on,hostfwd=tcp:127.0.0.1:$port-:22" + QEMU_ARGS=("${QEMU_ARGS[@]}" + -m "$mem" + -smp "$cpus" + -drive "if=virtio,format=qcow2,file=$disk" + -netdev "$netopt" + -device "virtio-net-pci,netdev=net0" + -device "virtio-rng-pci" + -display none + -serial "$serialspec") +} + +append_seed_args() { + local arch="$1" seed + if cloudinit_supported "$arch"; then + seed="$(seed_path "$arch")" + [ -f "$seed" ] || seed_arch "$arch" + QEMU_ARGS=("${QEMU_ARGS[@]}" + -drive "if=virtio,format=raw,media=cdrom,readonly=on,file=$seed") + fi +} + +# Machine + firmware + accel selection, shared by run and provision. Appends to +# an already-initialized QEMU_ARGS. +append_machine_args() { + local arch="$1" accel_args + case "$arch" in + amd64) + # Drop the default q35 VGA so FreeBSD selects the serial console as + # primary. Otherwise it picks the (headless) VGA as primary console and + # all userland/cloud-init output plus the login getty go to a head we + # never display, making a slow TCG boot indistinguishable from a hang. + QEMU_ARGS=("${QEMU_ARGS[@]}" -machine q35 -vga none) + append_firmware_args "$arch" + ;; + aarch64) + QEMU_ARGS=("${QEMU_ARGS[@]}" -machine virt) + accel_args="$(qemu_accel_args "$arch" | tr '\n' ' ')" + if [ -n "$accel_args" ]; then + # shellcheck disable=SC2206 + QEMU_ARGS=("${QEMU_ARGS[@]}" $accel_args -cpu host) + else + QEMU_ARGS=("${QEMU_ARGS[@]}" -cpu cortex-a72) + fi + append_firmware_args "$arch" + ;; + riscv64) + # acpi=off forces the edk2-riscv firmware to hand FreeBSD a flattened + # device tree (FDT) instead of ACPI tables. FreeBSD/riscv64 is FDT-only; + # with ACPI advertised, its loader finds "no valid device tree blob" and + # the kernel boots blind (no console/disk). + QEMU_ARGS=("${QEMU_ARGS[@]}" -machine virt,acpi=off -cpu rv64) + append_firmware_args "$arch" + ;; + esac +} + +# A "golden" disk is one whose first boot is already done: the login user and +# sshd are provisioned and the firstboot services have run. The build/ marker +# records that the working disk is golden; the DL_ROOT copy is the durable +# cache that survives `make clean` (which wipes build/). +provisioned_marker() { + echo "$VM_ROOT/images/freebsd-$(canon_arch "$1").provisioned" +} + +golden_cache_path() { + echo "$DL_ROOT/freebsd-$(canon_arch "$1")-golden.qcow2" +} + +# Save the (clean, provisioned) working disk to the durable golden cache, +# compressed so the cache stays small. +cache_golden() { + local arch="$1" disk golden + arch="$(canon_arch "$arch")" + disk="$(disk_path "$arch")" + golden="$(golden_cache_path "$arch")" + mkdir -p "$DL_ROOT" + printf 'cache golden disk: %s -> %s\n' "$disk" "$golden" + qemu-img convert -O qcow2 -c "$disk" "$golden.tmp" + mv "$golden.tmp" "$golden" +} + +# Boot a freshly-expanded cloud-init disk once, offline, so nuageinit creates +# the login user and starts sshd while firstboot_freebsd_update fails fast for +# lack of a route to the update servers (its slow networked update would +# otherwise run on the first boot and force a reboot -- painful under TCG). +# Once the guest reaches its login prompt the disk is golden. +provision_nuageinit() { + local arch="$1" disk port qemu log qpid t0 w + arch="$(canon_arch "$arch")" + disk="$(disk_path "$arch")" + port="$(ssh_port "$arch")" + qemu="$(qemu_bin "$arch")" + command -v "$qemu" >/dev/null 2>&1 || die "$qemu not found on PATH" + [ -f "$disk" ] || die "no disk to provision: $disk (run prepare first)" + [ -f "$(seed_path "$arch")" ] || seed_arch "$arch" + ensure_ssh_key + log="$VM_ROOT/images/freebsd-$arch.provision.log" + rm -f "$log" + QEMU_ARGS=() + append_machine_args "$arch" + append_common_args "$arch" "$disk" "$port" restrict "file:$log" + append_seed_args "$arch" + printf 'provision: booting %s offline to create user+sshd (log: %s)\n' "$arch" "$log" + "$qemu" "${QEMU_ARGS[@]}" >/dev/null 2>&1 & + qpid=$! + t0=$(date +%s) + while kill -0 "$qpid" 2>/dev/null; do + grep -q "login:" "$log" 2>/dev/null && break + if [ $(( $(date +%s) - t0 )) -gt "${KIT_FREEBSD_PROVISION_TIMEOUT:-1800}" ]; then + kill "$qpid" 2>/dev/null; wait "$qpid" 2>/dev/null || true + die "provision timed out for $arch after ${KIT_FREEBSD_PROVISION_TIMEOUT:-1800}s (see $log)" + fi + sleep 3 + done + kill -0 "$qpid" 2>/dev/null || die "provision VM for $arch exited before login (see $log)" + printf 'provision: %s reached login; shutting down cleanly\n' "$arch" + # sshd's banner is delayed ~30s under restrict (it reverse-DNS-resolves the + # client with no route out), so allow a generous connect timeout. A forced + # stop would also recover (UFS+SUJ journals), but a clean halt avoids an fsck. + sleep 5 + timeout 150 ssh -i "$SSH_KEY" -p "$port" \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -o ConnectTimeout=90 \ + "$SSH_USER@127.0.0.1" 'sync; shutdown -p now' >/dev/null 2>&1 || true + w=0 + while kill -0 "$qpid" 2>/dev/null && [ "$w" -lt 90 ]; do sleep 1; w=$((w+1)); done + if kill -0 "$qpid" 2>/dev/null; then + kill "$qpid" 2>/dev/null; wait "$qpid" 2>/dev/null || true + fi +} + +# Write the expect program that drives a first-boot setup over the serial +# console. Kept quoted (no shell expansion); it reads user/pubkey/timeout from +# the environment so the pubkey's `/ + =` never collide with shell quoting. +write_serial_provision_expect() { + cat > "$1" <<'EXP' +set timeout $env(KIT_PROV_TIMEOUT) +set user $env(KIT_PROV_USER) +set pubkey $env(KIT_PROV_PUBKEY) +spawn {*}$argv +set qpid [exp_pid] +proc bail {msg code} { + puts "\nprovision-serial: $msg" + catch { exec kill -9 $::qpid } + exit $code +} +expect { + timeout { bail "timed out waiting for login prompt" 2 } + "login: " +} +send "root\r" +expect { + timeout { bail "timed out after sending root" 2 } + "Login incorrect" { bail "root login refused (image expects a password)" 3 } + -re {Password:} { send "\r"; exp_continue } + -re {[#%$] $} {} +} +# Drop to /bin/sh and set a unique prompt. The `@@K""IT@@` we type evaluates to +# @@KIT@@ in the prompt but the typed echo contains the quotes, so expecting the +# bare marker only ever matches a real prompt, never the command echo. +send "/bin/sh\r" +send "PS1=@@K\"\"IT@@\r" +expect { + timeout { bail "timed out establishing shell prompt" 2 } + "@@KIT@@" +} +proc step {cmd} { + send "$cmd\r" + expect { + timeout { bail "timed out running: $cmd" 2 } + "@@KIT@@" + } +} +step "pw useradd -n $user -m -G wheel -s /bin/sh" +step "mkdir -p /home/$user/.ssh" +step "printf '%s\n' '$pubkey' > /home/$user/.ssh/authorized_keys" +step "chown -R $user:$user /home/$user/.ssh" +step "chmod 700 /home/$user/.ssh" +step "chmod 600 /home/$user/.ssh/authorized_keys" +step "sysrc sshd_enable=YES" +step "sysrc ifconfig_vtnet0=DHCP" +step "service sshd keygen" +send "sync; shutdown -p now\r" +expect { + timeout { bail "powered down but qemu still running; killed" 0 } + eof { exit 0 } +} +EXP +} + +# Provision an image that has no cloud-init seed (FreeBSD publishes no +# BASIC-CLOUDINIT riscv64 image). Drive the serial console with expect: log in +# as root, create the SSH user, enable sshd + DHCP, then power off. +provision_serial() { + local arch="$1" disk port qemu expf rc + arch="$(canon_arch "$arch")" + disk="$(disk_path "$arch")" + port="$(ssh_port "$arch")" + qemu="$(qemu_bin "$arch")" + command -v expect >/dev/null 2>&1 || + die "expect not found on PATH (needed to provision $arch over the serial console)" + command -v "$qemu" >/dev/null 2>&1 || die "$qemu not found on PATH" + [ -f "$disk" ] || die "no disk to provision: $disk (run prepare first)" + ensure_ssh_key + local mem="${KIT_FREEBSD_MEM:-2048}" cpus="${KIT_FREEBSD_CPUS:-2}" + QEMU_ARGS=() + append_machine_args "$arch" + QEMU_ARGS=("${QEMU_ARGS[@]}" + -m "$mem" + -smp "$cpus" + -drive "if=virtio,format=qcow2,file=$disk" + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$port-:22" + -device "virtio-net-pci,netdev=net0" + -device "virtio-rng-pci" + -display none + -serial stdio + -monitor none) + expf="$VM_ROOT/images/freebsd-$arch.provision.exp" + write_serial_provision_expect "$expf" + printf 'provision: booting %s to configure sshd over the serial console (slow under TCG)...\n' "$arch" + KIT_PROV_USER="$SSH_USER" \ + KIT_PROV_PUBKEY="$(cat "$SSH_KEY.pub")" \ + KIT_PROV_TIMEOUT="${KIT_FREEBSD_PROVISION_TIMEOUT:-1800}" \ + expect "$expf" "$qemu" "${QEMU_ARGS[@]}" + rc=$? + rm -f "$expf" + [ "$rc" -eq 0 ] || die "serial provisioning failed for $arch (expect rc=$rc)" +} + +# Provision a freshly-expanded disk into a golden one (nuageinit for cloud-init +# images, serial-console otherwise), then mark + cache it. +provision_arch() { + local arch="$1" + arch="$(canon_arch "$arch")" + if cloudinit_supported "$arch"; then + provision_nuageinit "$arch" + else + provision_serial "$arch" + fi + touch "$(provisioned_marker "$arch")" + cache_golden "$arch" + printf 'provisioned: %s\n' "$(disk_path "$arch")" +} + +# Make build/ hold a provisioned (golden) working disk for arch, preferring the +# cheapest source: an already-provisioned working disk, then the durable golden +# cache, then a from-scratch expand+provision. +ensure_disk() { + local arch="$1" disk marker golden src + arch="$(canon_arch "$arch")" + disk="$(disk_path "$arch")" + marker="$(provisioned_marker "$arch")" + golden="$(golden_cache_path "$arch")" + mkdir -p "$VM_ROOT/images" + [ -f "$disk" ] && [ -f "$marker" ] && return 0 + if [ -f "$golden" ]; then + printf 'restore golden disk: %s -> %s\n' "$golden" "$disk" + qemu-img convert -O qcow2 "$golden" "$disk.tmp" + mv "$disk.tmp" "$disk" + touch "$marker" + return 0 + fi + fetch_arch "$arch" + src="$(download_path "$arch")" + printf 'expand image: %s -> %s\n' "$src" "$disk" + xz -dc "$src" > "$disk.tmp" + mv "$disk.tmp" "$disk" + rm -f "$marker" + provision_arch "$arch" +} + +run_arch() { + local arch="$1" disk port qemu + shift + arch="$(canon_arch "$arch")" + disk="$(disk_path "$arch")" + port="$(ssh_port "$arch")" + qemu="$(qemu_bin "$arch")" + command -v "$qemu" >/dev/null 2>&1 || die "$qemu not found on PATH" + ensure_disk "$arch" + QEMU_ARGS=() + append_machine_args "$arch" + append_common_args "$arch" "$disk" "$port" + append_seed_args "$arch" + printf 'ssh forward: 127.0.0.1:%s -> %s:22\n' "$port" "$arch" + printf 'run: %s\n' "$qemu" + exec "$qemu" "${QEMU_ARGS[@]}" "$@" +} + +ssh_args() { + local arch="$1" port + port="$(ssh_port "$arch")" + printf '%s\n' -i "$SSH_KEY" \ + -p "$port" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR \ + -o ConnectTimeout=5 +} + +wait_ssh() { + local arch="$1" port args + arch="$(canon_arch "$arch")" + port="$(ssh_port "$arch")" + if [ ! -f "$(provisioned_marker "$arch")" ] && [ ! -f "$(golden_cache_path "$arch")" ]; then + die "$arch is not provisioned yet; run 'prepare $arch' or 'provision $arch' first" + fi + [ -f "$SSH_KEY" ] || die "missing SSH key: $SSH_KEY" + printf 'waiting for ssh on 127.0.0.1:%s as %s\n' "$port" "$SSH_USER" + # shellcheck disable=SC2207 + args=($(ssh_args "$arch")) + for _ in $(jot 120 1 120 2>/dev/null || seq 1 120); do + if ssh "${args[@]}" "$SSH_USER@127.0.0.1" 'uname -a' 2>/dev/null; then + return 0 + fi + sleep 2 + done + die "ssh did not become ready for $arch" +} + +ssh_arch() { + local arch="$1" args + shift + arch="$(canon_arch "$arch")" + # shellcheck disable=SC2207 + args=($(ssh_args "$arch")) + exec ssh "${args[@]}" "$SSH_USER@127.0.0.1" "$@" +} + +doctor() { + printf 'host: %s/%s\n' "$(uname -s 2>/dev/null)" "$(uname -m 2>/dev/null)" + printf 'vm root: %s\n' "$VM_ROOT" + printf 'download cache: %s\n' "$DL_ROOT" + for tool in curl shasum xz hdiutil ssh-keygen ssh qemu-img \ + qemu-system-x86_64 qemu-system-aarch64 qemu-system-riscv64; do + if command -v "$tool" >/dev/null 2>&1; then + printf ' OK %s (%s)\n' "$tool" "$(command -v "$tool")" + else + printf ' MISSING %s\n' "$tool" + fi + done + for arch in amd64 aarch64 riscv64; do + printf ' %-7s cloudinit=%s ssh_port=%s golden=%s\n' \ + "$arch" \ + "$(cloudinit_supported "$arch" && echo yes || echo no)" \ + "$(ssh_port "$arch")" \ + "$([ -f "$(golden_cache_path "$arch")" ] && echo cached || echo none)" + done +} + +cmd="${1:-}" +case "$cmd" in + doctor) doctor ;; + fetch) [ $# -eq 2 ] || { usage; exit 2; }; fetch_arch "$2" ;; + prepare) [ $# -eq 2 ] || { usage; exit 2; }; prepare_arch "$2" ;; + seed) [ $# -eq 2 ] || { usage; exit 2; }; seed_arch "$2" ;; + provision) [ $# -eq 2 ] || { usage; exit 2; }; provision_arch "$2" ;; + run) [ $# -ge 2 ] || { usage; exit 2; }; arch="$2"; shift 2; run_arch "$arch" "$@" ;; + wait-ssh) [ $# -eq 2 ] || { usage; exit 2; }; wait_ssh "$2" ;; + ssh) [ $# -ge 2 ] || { usage; exit 2; }; arch="$2"; shift 2; ssh_arch "$arch" "$@" ;; + -h|--help|help|"") usage ;; + *) usage; exit 2 ;; +esac