kit

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

freebsd_vm.sh (25373B)


      1 #!/usr/bin/env bash
      2 # Prepare and run FreeBSD release VMs used as execution targets for kit tests.
      3 #
      4 # Defaults target FreeBSD 15.0-RELEASE VM images:
      5 #   amd64   -> BASIC-CLOUDINIT UFS qcow2
      6 #   aarch64 -> BASIC-CLOUDINIT UFS qcow2
      7 #   riscv64 -> UFS qcow2 (no BASIC-CLOUDINIT image is published)
      8 #
      9 # Downloaded .xz images live under XDG cache by default so `make clean` does
     10 # not remove them. Expanded VM disks, firmware vars, and seed ISOs live under
     11 # build/freebsd-vm/ by default.
     12 
     13 set -eu
     14 
     15 ROOT="$(cd "$(dirname "$0")/.." && pwd)"
     16 VM_ROOT="${KIT_FREEBSD_VM_DIR:-$ROOT/build/freebsd-vm}"
     17 RELEASE="${KIT_FREEBSD_RELEASE:-15.0-RELEASE}"
     18 XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
     19 DL_ROOT="${KIT_FREEBSD_DOWNLOAD_DIR:-$XDG_CACHE_HOME/kit/freebsd-vm/$RELEASE}"
     20 BASE_URL="${KIT_FREEBSD_BASE_URL:-https://download.freebsd.org/releases/VM-IMAGES/$RELEASE}"
     21 QEMU_SHARE="${KIT_QEMU_SHARE:-/opt/homebrew/share/qemu}"
     22 SSH_USER="${KIT_FREEBSD_SSH_USER:-kit}"
     23 # The SSH key lives in the download cache (alongside the golden disks) so
     24 # `make clean` does not wipe it -- the key is baked into the golden disk's
     25 # authorized_keys, so losing it would lock the cached disks out. (Sysroots are
     26 # built VM-free from base.txz; see scripts/freebsd_sysroot.sh.)
     27 SSH_KEY="${KIT_FREEBSD_SSH_KEY:-$DL_ROOT/ssh/id_ed25519}"
     28 
     29 usage() {
     30   cat <<EOF
     31 usage: scripts/freebsd_vm.sh <command> <arch> [args...]
     32 
     33 commands:
     34   doctor                 print local QEMU/image tool availability
     35   fetch <arch>           download image and checksum
     36   prepare <arch>         restore/build a provisioned, ready-to-run disk
     37   seed <arch>            create a NoCloud cidata ISO for cloud-init images
     38   provision <arch>       boot once offline to create user+sshd, cache golden disk
     39   run <arch> [qemu...]   run VM in foreground with host-only SSH forwarding
     40   wait-ssh <arch>        wait for SSH and print uname
     41   ssh <arch> [cmd...]    SSH into a running VM
     42   scp <arch> <src> <dst>  copy a file into the running VM (dst is remote path)
     43   run-batch <arch> <dir> <res>
     44                          ship a staging dir into the VM, run its run-remote.sh,
     45                          and bring per-binary res/<id>.{rc,out,err} back into
     46                          <res> (used by test/lib/exec_vm.sh)
     47 
     48 arches:
     49   amd64 | x64 | aarch64 | arm64 | rv64 | riscv64
     50 
     51 env:
     52   KIT_FREEBSD_VM_DIR     artifact directory (default: build/freebsd-vm)
     53   KIT_FREEBSD_DOWNLOAD_DIR
     54                          downloaded .xz/checksum cache
     55                          (default: \${XDG_CACHE_HOME:-\$HOME/.cache}/kit/freebsd-vm/<release>)
     56   KIT_FREEBSD_RELEASE    FreeBSD release (default: 15.0-RELEASE)
     57   KIT_FREEBSD_MEM        QEMU memory in MiB (default: 2048)
     58   KIT_FREEBSD_CPUS       QEMU CPU count (default: 2)
     59   KIT_FREEBSD_PROVISION_TIMEOUT
     60                          seconds to wait for the offline provisioning boot to
     61                          reach a login prompt (default: 1800)
     62   KIT_FREEBSD_ACCEL      QEMU accel override (default: hvf for aarch64 on arm64 macOS)
     63   KIT_FREEBSD_SSH_USER   cloud-init user (default: kit)
     64   KIT_FREEBSD_SSH_KEY    private SSH key path
     65 EOF
     66 }
     67 
     68 die() {
     69   printf 'freebsd-vm: %s\n' "$*" >&2
     70   exit 1
     71 }
     72 
     73 canon_arch() {
     74   case "${1:-}" in
     75     amd64|x64|x86_64) echo amd64 ;;
     76     aarch64|arm64|aa64) echo aarch64 ;;
     77     riscv64|rv64) echo riscv64 ;;
     78     *) die "unknown arch '${1:-}'" ;;
     79   esac
     80 }
     81 
     82 arch_dir() {
     83   case "$(canon_arch "$1")" in
     84     amd64) echo amd64 ;;
     85     aarch64) echo aarch64 ;;
     86     riscv64) echo riscv64 ;;
     87   esac
     88 }
     89 
     90 image_file() {
     91   case "$(canon_arch "$1")" in
     92     amd64) echo "FreeBSD-$RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz" ;;
     93     aarch64) echo "FreeBSD-$RELEASE-arm64-aarch64-BASIC-CLOUDINIT-ufs.qcow2.xz" ;;
     94     riscv64) echo "FreeBSD-$RELEASE-riscv-riscv64-ufs.qcow2.xz" ;;
     95   esac
     96 }
     97 
     98 cloudinit_supported() {
     99   case "$(canon_arch "$1")" in
    100     amd64|aarch64) return 0 ;;
    101     riscv64) return 1 ;;
    102   esac
    103 }
    104 
    105 ssh_port() {
    106   case "$(canon_arch "$1")" in
    107     amd64) echo "${KIT_FREEBSD_AMD64_SSH_PORT:-2222}" ;;
    108     aarch64) echo "${KIT_FREEBSD_AARCH64_SSH_PORT:-2223}" ;;
    109     riscv64) echo "${KIT_FREEBSD_RISCV64_SSH_PORT:-2224}" ;;
    110   esac
    111 }
    112 
    113 qemu_bin() {
    114   case "$(canon_arch "$1")" in
    115     amd64) echo qemu-system-x86_64 ;;
    116     aarch64) echo qemu-system-aarch64 ;;
    117     riscv64) echo qemu-system-riscv64 ;;
    118   esac
    119 }
    120 
    121 disk_path() {
    122   echo "$VM_ROOT/images/freebsd-$(canon_arch "$1").qcow2"
    123 }
    124 
    125 seed_path() {
    126   echo "$VM_ROOT/seeds/freebsd-$(canon_arch "$1")-seed.iso"
    127 }
    128 
    129 checksum_path() {
    130   echo "$DL_ROOT/$(canon_arch "$1")-CHECKSUM.SHA256"
    131 }
    132 
    133 download_path() {
    134   echo "$DL_ROOT/$(image_file "$1")"
    135 }
    136 
    137 host_is_darwin_arm64() {
    138   [ "$(uname -s 2>/dev/null)" = "Darwin" ] &&
    139     { [ "$(uname -m 2>/dev/null)" = "arm64" ] ||
    140       [ "$(uname -m 2>/dev/null)" = "aarch64" ]; }
    141 }
    142 
    143 qemu_accel_args() {
    144   local arch="$1" accel="${KIT_FREEBSD_ACCEL:-}"
    145   if [ -z "$accel" ] && [ "$(canon_arch "$arch")" = "aarch64" ] &&
    146      host_is_darwin_arm64; then
    147     accel=hvf
    148   fi
    149   [ -n "$accel" ] && printf '%s\n%s\n' -accel "$accel"
    150 }
    151 
    152 firmware_code() {
    153   case "$(canon_arch "$1")" in
    154     amd64) echo "$QEMU_SHARE/edk2-x86_64-code.fd" ;;
    155     aarch64) echo "$QEMU_SHARE/edk2-aarch64-code.fd" ;;
    156     riscv64) echo "$QEMU_SHARE/edk2-riscv-code.fd" ;;
    157   esac
    158 }
    159 
    160 firmware_vars_template() {
    161   case "$(canon_arch "$1")" in
    162     amd64) echo "$QEMU_SHARE/edk2-i386-vars.fd" ;;
    163     aarch64) echo "$QEMU_SHARE/edk2-arm-vars.fd" ;;
    164     riscv64) echo "$QEMU_SHARE/edk2-riscv-vars.fd" ;;
    165   esac
    166 }
    167 
    168 firmware_vars() {
    169   echo "$VM_ROOT/firmware/$(canon_arch "$1")-vars.fd"
    170 }
    171 
    172 ensure_ssh_key() {
    173   if [ ! -f "$SSH_KEY" ]; then
    174     mkdir -p "$(dirname "$SSH_KEY")"
    175     ssh-keygen -q -t ed25519 -N "" -f "$SSH_KEY"
    176   fi
    177 }
    178 
    179 fetch_arch() {
    180   local arch="$1" dir file url cksum
    181   arch="$(canon_arch "$arch")"
    182   dir="$(arch_dir "$arch")"
    183   file="$(image_file "$arch")"
    184   url="$BASE_URL/$dir/Latest/$file"
    185   mkdir -p "$DL_ROOT"
    186   printf 'fetch checksum: %s\n' "$BASE_URL/$dir/Latest/CHECKSUM.SHA256"
    187   curl -fL --retry 3 --retry-delay 2 -o "$(checksum_path "$arch")" \
    188     "$BASE_URL/$dir/Latest/CHECKSUM.SHA256"
    189   printf 'fetch image: %s\n' "$url"
    190   curl -fL --continue-at - --retry 3 --retry-delay 2 \
    191     -o "$(download_path "$arch")" "$url"
    192   cksum="$(awk -v f="$file" '$2 == "(" f ")" && $3 == "=" { print $4 }' \
    193     "$(checksum_path "$arch")")"
    194   [ -n "$cksum" ] || die "checksum entry missing for $file"
    195   verify_sha256 "$arch" "$cksum"
    196 }
    197 
    198 verify_sha256() {
    199   local arch="$1" expect="$2" got
    200   got="$(shasum -a 256 "$(download_path "$arch")" | awk '{ print $1 }')"
    201   [ "$got" = "$expect" ] ||
    202     die "checksum mismatch for $(image_file "$arch"): got $got want $expect"
    203   printf 'verified: %s\n' "$(image_file "$arch")"
    204 }
    205 
    206 # Produce a ready-to-run, provisioned disk: restore the cached golden disk if we
    207 # have one, otherwise download/expand the pristine image and provision it. The
    208 # heavy lifting lives in ensure_disk (defined below).
    209 prepare_arch() {
    210   ensure_disk "$1"
    211 }
    212 
    213 seed_arch() {
    214   local arch="$1" seed_dir seed key
    215   arch="$(canon_arch "$arch")"
    216   cloudinit_supported "$arch" ||
    217     die "$arch does not have a FreeBSD BASIC-CLOUDINIT VM image"
    218   ensure_ssh_key
    219   mkdir -p "$VM_ROOT/seeds"
    220   seed_dir="$VM_ROOT/seeds/$(canon_arch "$arch")"
    221   seed="$(seed_path "$arch")"
    222   key="$(cat "$SSH_KEY.pub")"
    223   rm -rf "$seed_dir"
    224   mkdir -p "$seed_dir"
    225   cat > "$seed_dir/meta-data" <<EOF
    226 local-hostname: kit-freebsd-$arch
    227 EOF
    228   # nuageinit (FreeBSD's native cloud-init) processes this #cloud-config.
    229   # runcmd only re-asserts sshd_enable (idempotent): sshd is already enabled in
    230   # the image rc.conf, so the base rc starts it exactly once. We deliberately do
    231   # NOT "service sshd start" here -- that races the base rc start and dies with
    232   # "Bind to port 22 ... Address already in use".
    233   cat > "$seed_dir/user-data" <<EOF
    234 #cloud-config
    235 users:
    236   - name: $SSH_USER
    237     gecos: Kit FreeBSD Test User
    238     groups: wheel
    239     homedir: /home/$SSH_USER
    240     shell: /bin/sh
    241     sudo: ALL=(ALL) NOPASSWD:ALL
    242     ssh_authorized_keys:
    243       - "$key"
    244 ssh_authorized_keys:
    245   - "$key"
    246 network:
    247   version: 2
    248   ethernets:
    249     vtnet0:
    250       dhcp4: true
    251 runcmd:
    252   - sysrc sshd_enable=YES
    253 EOF
    254   rm -f "$seed"
    255   hdiutil makehybrid -quiet -iso -joliet -default-volume-name cidata \
    256     -o "$seed" "$seed_dir"
    257   printf 'seed ready: %s\n' "$seed"
    258 }
    259 
    260 ensure_firmware_vars() {
    261   local arch="$1" code vars tmpl
    262   code="$(firmware_code "$arch")"
    263   vars="$(firmware_vars "$arch")"
    264   tmpl="$(firmware_vars_template "$arch")"
    265   [ -f "$code" ] || die "missing QEMU firmware code: $code"
    266   [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl"
    267   mkdir -p "$(dirname "$vars")"
    268   # Always start from the pristine template. The EDK2 firmware persists its
    269   # boot order / NVRAM into this vars file; once it drifts (e.g. after a run
    270   # with a different device set) it can boot the built-in UEFI Shell instead
    271   # of chainloading the disk, which looks exactly like a hang. Resetting per
    272   # run keeps the boot path deterministic; nothing here relies on NVRAM
    273   # persisting across runs.
    274   cp "$tmpl" "$vars"
    275 }
    276 
    277 append_firmware_args() {
    278   local arch="$1" code vars
    279   ensure_firmware_vars "$arch"
    280   code="$(firmware_code "$arch")"
    281   vars="$(firmware_vars "$arch")"
    282   QEMU_ARGS=("${QEMU_ARGS[@]}"
    283     -drive "if=pflash,format=raw,readonly=on,file=$code"
    284     -drive "if=pflash,format=raw,file=$vars")
    285 }
    286 
    287 # append_common_args ARCH DISK PORT [RESTRICT] [SERIALSPEC]
    288 #   RESTRICT non-empty -> isolate the guest from the outside network. DHCP and
    289 #     the SSH hostfwd still work, but outbound connections fail fast. Used while
    290 #     provisioning so firstboot_freebsd_update cannot run its slow networked
    291 #     update (and force a reboot) on the first boot.
    292 #   SERIALSPEC defaults to "mon:stdio" (interactive foreground). Provisioning
    293 #     passes "file:<log>" so a backgrounded boot can be watched.
    294 append_common_args() {
    295   local arch="$1" disk="$2" port="$3" restrict="${4:-}" serialspec="${5:-mon:stdio}"
    296   local mem="${KIT_FREEBSD_MEM:-2048}" cpus="${KIT_FREEBSD_CPUS:-2}"
    297   local netopt="user,id=net0,hostfwd=tcp:127.0.0.1:$port-:22"
    298   [ -n "$restrict" ] && netopt="user,id=net0,restrict=on,hostfwd=tcp:127.0.0.1:$port-:22"
    299   QEMU_ARGS=("${QEMU_ARGS[@]}"
    300     -m "$mem"
    301     -smp "$cpus"
    302     -drive "if=virtio,format=qcow2,file=$disk"
    303     -netdev "$netopt"
    304     -device "virtio-net-pci,netdev=net0"
    305     -device "virtio-rng-pci"
    306     -display none
    307     -serial "$serialspec")
    308 }
    309 
    310 append_seed_args() {
    311   local arch="$1" seed
    312   if cloudinit_supported "$arch"; then
    313     seed="$(seed_path "$arch")"
    314     [ -f "$seed" ] || seed_arch "$arch"
    315     QEMU_ARGS=("${QEMU_ARGS[@]}"
    316       -drive "if=virtio,format=raw,media=cdrom,readonly=on,file=$seed")
    317   fi
    318 }
    319 
    320 # Machine + firmware + accel selection, shared by run and provision. Appends to
    321 # an already-initialized QEMU_ARGS.
    322 append_machine_args() {
    323   local arch="$1" accel_args
    324   case "$arch" in
    325     amd64)
    326       # Drop the default q35 VGA so FreeBSD selects the serial console as
    327       # primary. Otherwise it picks the (headless) VGA as primary console and
    328       # all userland/cloud-init output plus the login getty go to a head we
    329       # never display, making a slow TCG boot indistinguishable from a hang.
    330       #
    331       # QEMU's default x86_64 TCG model is "qemu64" (~x86-64-v1, no
    332       # POPCNT/SSE4.2/BMI). kit targets a modern x86_64 baseline and emits e.g.
    333       # `popcnt` for @popcount, which #UDs (SIGILL -> exit 132) on qemu64. Add the
    334       # missing baseline-ish features explicitly: "-cpu max" enables enough that
    335       # the FreeBSD/EDK2 boot fails under TCG, so extend qemu64 minimally instead.
    336       QEMU_ARGS=("${QEMU_ARGS[@]}" -machine q35 -vga none \
    337         -cpu "qemu64,+popcnt,+sse4.1,+sse4.2")
    338       append_firmware_args "$arch"
    339       ;;
    340     aarch64)
    341       QEMU_ARGS=("${QEMU_ARGS[@]}" -machine virt)
    342       accel_args="$(qemu_accel_args "$arch" | tr '\n' ' ')"
    343       if [ -n "$accel_args" ]; then
    344         # shellcheck disable=SC2206
    345         QEMU_ARGS=("${QEMU_ARGS[@]}" $accel_args -cpu host)
    346       else
    347         QEMU_ARGS=("${QEMU_ARGS[@]}" -cpu cortex-a72)
    348       fi
    349       append_firmware_args "$arch"
    350       ;;
    351     riscv64)
    352       # acpi=off forces the edk2-riscv firmware to hand FreeBSD a flattened
    353       # device tree (FDT) instead of ACPI tables. FreeBSD/riscv64 is FDT-only;
    354       # with ACPI advertised, its loader finds "no valid device tree blob" and
    355       # the kernel boots blind (no console/disk).
    356       QEMU_ARGS=("${QEMU_ARGS[@]}" -machine virt,acpi=off -cpu rv64)
    357       append_firmware_args "$arch"
    358       ;;
    359   esac
    360 }
    361 
    362 # A "golden" disk is one whose first boot is already done: the login user and
    363 # sshd are provisioned and the firstboot services have run. The build/ marker
    364 # records that the working disk is golden; the DL_ROOT copy is the durable
    365 # cache that survives `make clean` (which wipes build/).
    366 provisioned_marker() {
    367   echo "$VM_ROOT/images/freebsd-$(canon_arch "$1").provisioned"
    368 }
    369 
    370 golden_cache_path() {
    371   echo "$DL_ROOT/freebsd-$(canon_arch "$1")-golden.qcow2"
    372 }
    373 
    374 # Save the (clean, provisioned) working disk to the durable golden cache,
    375 # compressed so the cache stays small.
    376 cache_golden() {
    377   local arch="$1" disk golden
    378   arch="$(canon_arch "$arch")"
    379   disk="$(disk_path "$arch")"
    380   golden="$(golden_cache_path "$arch")"
    381   mkdir -p "$DL_ROOT"
    382   printf 'cache golden disk: %s -> %s\n' "$disk" "$golden"
    383   qemu-img convert -O qcow2 -c "$disk" "$golden.tmp"
    384   mv "$golden.tmp" "$golden"
    385 }
    386 
    387 # Boot a freshly-expanded cloud-init disk once, offline, so nuageinit creates
    388 # the login user and starts sshd while firstboot_freebsd_update fails fast for
    389 # lack of a route to the update servers (its slow networked update would
    390 # otherwise run on the first boot and force a reboot -- painful under TCG).
    391 # Once the guest reaches its login prompt the disk is golden.
    392 provision_nuageinit() {
    393   local arch="$1" disk port qemu log qpid t0 w
    394   arch="$(canon_arch "$arch")"
    395   disk="$(disk_path "$arch")"
    396   port="$(ssh_port "$arch")"
    397   qemu="$(qemu_bin "$arch")"
    398   command -v "$qemu" >/dev/null 2>&1 || die "$qemu not found on PATH"
    399   [ -f "$disk" ] || die "no disk to provision: $disk (run prepare first)"
    400   [ -f "$(seed_path "$arch")" ] || seed_arch "$arch"
    401   ensure_ssh_key
    402   log="$VM_ROOT/images/freebsd-$arch.provision.log"
    403   rm -f "$log"
    404   QEMU_ARGS=()
    405   append_machine_args "$arch"
    406   append_common_args "$arch" "$disk" "$port" restrict "file:$log"
    407   append_seed_args "$arch"
    408   printf 'provision: booting %s offline to create user+sshd (log: %s)\n' "$arch" "$log"
    409   "$qemu" "${QEMU_ARGS[@]}" >/dev/null 2>&1 &
    410   qpid=$!
    411   t0=$(date +%s)
    412   while kill -0 "$qpid" 2>/dev/null; do
    413     grep -q "login:" "$log" 2>/dev/null && break
    414     if [ $(( $(date +%s) - t0 )) -gt "${KIT_FREEBSD_PROVISION_TIMEOUT:-1800}" ]; then
    415       kill "$qpid" 2>/dev/null; wait "$qpid" 2>/dev/null || true
    416       die "provision timed out for $arch after ${KIT_FREEBSD_PROVISION_TIMEOUT:-1800}s (see $log)"
    417     fi
    418     sleep 3
    419   done
    420   kill -0 "$qpid" 2>/dev/null || die "provision VM for $arch exited before login (see $log)"
    421   printf 'provision: %s reached login; shutting down cleanly\n' "$arch"
    422   # sshd's banner is delayed ~30s under restrict (it reverse-DNS-resolves the
    423   # client with no route out), so allow a generous connect timeout. A forced
    424   # stop would also recover (UFS+SUJ journals), but a clean halt avoids an fsck.
    425   sleep 5
    426   timeout 150 ssh -i "$SSH_KEY" -p "$port" \
    427     -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    428     -o LogLevel=ERROR -o ConnectTimeout=90 \
    429     "$SSH_USER@127.0.0.1" 'sync; shutdown -p now' >/dev/null 2>&1 || true
    430   w=0
    431   while kill -0 "$qpid" 2>/dev/null && [ "$w" -lt 90 ]; do sleep 1; w=$((w+1)); done
    432   if kill -0 "$qpid" 2>/dev/null; then
    433     kill "$qpid" 2>/dev/null; wait "$qpid" 2>/dev/null || true
    434   fi
    435 }
    436 
    437 # Write the expect program that drives a first-boot setup over the serial
    438 # console. Kept quoted (no shell expansion); it reads user/pubkey/timeout from
    439 # the environment so the pubkey's `/ + =` never collide with shell quoting.
    440 write_serial_provision_expect() {
    441   cat > "$1" <<'EXP'
    442 set timeout $env(KIT_PROV_TIMEOUT)
    443 set user $env(KIT_PROV_USER)
    444 set pubkey $env(KIT_PROV_PUBKEY)
    445 spawn {*}$argv
    446 set qpid [exp_pid]
    447 proc bail {msg code} {
    448   puts "\nprovision-serial: $msg"
    449   catch { exec kill -9 $::qpid }
    450   exit $code
    451 }
    452 expect {
    453   timeout { bail "timed out waiting for login prompt" 2 }
    454   "login: "
    455 }
    456 send "root\r"
    457 expect {
    458   timeout { bail "timed out after sending root" 2 }
    459   "Login incorrect" { bail "root login refused (image expects a password)" 3 }
    460   -re {Password:} { send "\r"; exp_continue }
    461   -re {[#%$] $} {}
    462 }
    463 # Drop to /bin/sh and set a unique prompt. The `@@K""IT@@` we type evaluates to
    464 # @@KIT@@ in the prompt but the typed echo contains the quotes, so expecting the
    465 # bare marker only ever matches a real prompt, never the command echo.
    466 send "/bin/sh\r"
    467 send "PS1=@@K\"\"IT@@\r"
    468 expect {
    469   timeout { bail "timed out establishing shell prompt" 2 }
    470   "@@KIT@@"
    471 }
    472 proc step {cmd} {
    473   send "$cmd\r"
    474   expect {
    475     timeout { bail "timed out running: $cmd" 2 }
    476     "@@KIT@@"
    477   }
    478 }
    479 step "pw useradd -n $user -m -G wheel -s /bin/sh"
    480 step "mkdir -p /home/$user/.ssh"
    481 step "printf '%s\n' '$pubkey' > /home/$user/.ssh/authorized_keys"
    482 step "chown -R $user:$user /home/$user/.ssh"
    483 step "chmod 700 /home/$user/.ssh"
    484 step "chmod 600 /home/$user/.ssh/authorized_keys"
    485 step "sysrc sshd_enable=YES"
    486 step "sysrc ifconfig_vtnet0=DHCP"
    487 step "service sshd keygen"
    488 send "sync; shutdown -p now\r"
    489 expect {
    490   timeout { bail "powered down but qemu still running; killed" 0 }
    491   eof { exit 0 }
    492 }
    493 EXP
    494 }
    495 
    496 # Provision an image that has no cloud-init seed (FreeBSD publishes no
    497 # BASIC-CLOUDINIT riscv64 image). Drive the serial console with expect: log in
    498 # as root, create the SSH user, enable sshd + DHCP, then power off.
    499 provision_serial() {
    500   local arch="$1" disk port qemu expf rc
    501   arch="$(canon_arch "$arch")"
    502   disk="$(disk_path "$arch")"
    503   port="$(ssh_port "$arch")"
    504   qemu="$(qemu_bin "$arch")"
    505   command -v expect >/dev/null 2>&1 ||
    506     die "expect not found on PATH (needed to provision $arch over the serial console)"
    507   command -v "$qemu" >/dev/null 2>&1 || die "$qemu not found on PATH"
    508   [ -f "$disk" ] || die "no disk to provision: $disk (run prepare first)"
    509   ensure_ssh_key
    510   local mem="${KIT_FREEBSD_MEM:-2048}" cpus="${KIT_FREEBSD_CPUS:-2}"
    511   QEMU_ARGS=()
    512   append_machine_args "$arch"
    513   QEMU_ARGS=("${QEMU_ARGS[@]}"
    514     -m "$mem"
    515     -smp "$cpus"
    516     -drive "if=virtio,format=qcow2,file=$disk"
    517     -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:$port-:22"
    518     -device "virtio-net-pci,netdev=net0"
    519     -device "virtio-rng-pci"
    520     -display none
    521     -serial stdio
    522     -monitor none)
    523   expf="$VM_ROOT/images/freebsd-$arch.provision.exp"
    524   write_serial_provision_expect "$expf"
    525   printf 'provision: booting %s to configure sshd over the serial console (slow under TCG)...\n' "$arch"
    526   KIT_PROV_USER="$SSH_USER" \
    527   KIT_PROV_PUBKEY="$(cat "$SSH_KEY.pub")" \
    528   KIT_PROV_TIMEOUT="${KIT_FREEBSD_PROVISION_TIMEOUT:-1800}" \
    529     expect "$expf" "$qemu" "${QEMU_ARGS[@]}"
    530   rc=$?
    531   rm -f "$expf"
    532   [ "$rc" -eq 0 ] || die "serial provisioning failed for $arch (expect rc=$rc)"
    533 }
    534 
    535 # Provision a freshly-expanded disk into a golden one (nuageinit for cloud-init
    536 # images, serial-console otherwise), then mark + cache it.
    537 provision_arch() {
    538   local arch="$1"
    539   arch="$(canon_arch "$arch")"
    540   if cloudinit_supported "$arch"; then
    541     provision_nuageinit "$arch"
    542   else
    543     provision_serial "$arch"
    544   fi
    545   touch "$(provisioned_marker "$arch")"
    546   cache_golden "$arch"
    547   printf 'provisioned: %s\n' "$(disk_path "$arch")"
    548 }
    549 
    550 # Make build/ hold a provisioned (golden) working disk for arch, preferring the
    551 # cheapest source: an already-provisioned working disk, then the durable golden
    552 # cache, then a from-scratch expand+provision.
    553 ensure_disk() {
    554   local arch="$1" disk marker golden src
    555   arch="$(canon_arch "$arch")"
    556   disk="$(disk_path "$arch")"
    557   marker="$(provisioned_marker "$arch")"
    558   golden="$(golden_cache_path "$arch")"
    559   mkdir -p "$VM_ROOT/images"
    560   [ -f "$disk" ] && [ -f "$marker" ] && return 0
    561   if [ -f "$golden" ]; then
    562     printf 'restore golden disk: %s -> %s\n' "$golden" "$disk"
    563     qemu-img convert -O qcow2 "$golden" "$disk.tmp"
    564     mv "$disk.tmp" "$disk"
    565     touch "$marker"
    566     return 0
    567   fi
    568   fetch_arch "$arch"
    569   src="$(download_path "$arch")"
    570   printf 'expand image: %s -> %s\n' "$src" "$disk"
    571   xz -dc "$src" > "$disk.tmp"
    572   mv "$disk.tmp" "$disk"
    573   rm -f "$marker"
    574   provision_arch "$arch"
    575 }
    576 
    577 run_arch() {
    578   local arch="$1" disk port qemu
    579   shift
    580   arch="$(canon_arch "$arch")"
    581   disk="$(disk_path "$arch")"
    582   port="$(ssh_port "$arch")"
    583   qemu="$(qemu_bin "$arch")"
    584   command -v "$qemu" >/dev/null 2>&1 || die "$qemu not found on PATH"
    585   ensure_disk "$arch"
    586   QEMU_ARGS=()
    587   append_machine_args "$arch"
    588   append_common_args "$arch" "$disk" "$port"
    589   append_seed_args "$arch"
    590   printf 'ssh forward: 127.0.0.1:%s -> %s:22\n' "$port" "$arch"
    591   printf 'run: %s\n' "$qemu"
    592   exec "$qemu" "${QEMU_ARGS[@]}" "$@"
    593 }
    594 
    595 ssh_args() {
    596   local arch="$1" port
    597   port="$(ssh_port "$arch")"
    598   printf '%s\n' -i "$SSH_KEY" \
    599     -p "$port" \
    600     -o StrictHostKeyChecking=no \
    601     -o UserKnownHostsFile=/dev/null \
    602     -o LogLevel=ERROR \
    603     -o ConnectTimeout=5
    604 }
    605 
    606 wait_ssh() {
    607   local arch="$1" port args
    608   arch="$(canon_arch "$arch")"
    609   port="$(ssh_port "$arch")"
    610   if [ ! -f "$(provisioned_marker "$arch")" ] && [ ! -f "$(golden_cache_path "$arch")" ]; then
    611     die "$arch is not provisioned yet; run 'prepare $arch' or 'provision $arch' first"
    612   fi
    613   [ -f "$SSH_KEY" ] || die "missing SSH key: $SSH_KEY"
    614   printf 'waiting for ssh on 127.0.0.1:%s as %s\n' "$port" "$SSH_USER"
    615   # shellcheck disable=SC2207
    616   args=($(ssh_args "$arch"))
    617   for _ in $(jot 120 1 120 2>/dev/null || seq 1 120); do
    618     if ssh "${args[@]}" "$SSH_USER@127.0.0.1" 'uname -a' 2>/dev/null; then
    619       return 0
    620     fi
    621     sleep 2
    622   done
    623   die "ssh did not become ready for $arch"
    624 }
    625 
    626 ssh_arch() {
    627   local arch="$1" args
    628   shift
    629   arch="$(canon_arch "$arch")"
    630   # shellcheck disable=SC2207
    631   args=($(ssh_args "$arch"))
    632   exec ssh "${args[@]}" "$SSH_USER@127.0.0.1" "$@"
    633 }
    634 
    635 scp_arch() {
    636   local arch="$1" src="$2" dst="$3" port args
    637   arch="$(canon_arch "$arch")"
    638   port="$(ssh_port "$arch")"
    639   # shellcheck disable=SC2207
    640   args=($(ssh_args "$arch"))
    641   exec scp -P "$port" -i "$SSH_KEY" \
    642     -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
    643     "$src" "$SSH_USER@127.0.0.1:$dst"
    644 }
    645 
    646 # run_batch ARCH STAGEDIR RESULTSDIR
    647 #   Run a whole staging dir in a single SSH session and bring per-binary results
    648 #   back. Pure transport: tar the stagedir in on stdin, the guest extracts it and
    649 #   runs its run-remote.sh entry script (authored by the caller; it writes
    650 #   res/<id>.{rc,out,err} per binary), then tars res/ back on stdout, which we
    651 #   extract flat into RESULTSDIR. One VM round-trip per arch. The VM must already
    652 #   be reachable (the caller boots it and waits for SSH).
    653 run_batch_arch() {
    654   local arch="$1" stagedir="$2" resultsdir="$3" args
    655   arch="$(canon_arch "$arch")"
    656   [ -d "$stagedir" ] || die "run-batch: no such staging dir: $stagedir"
    657   [ -f "$stagedir/run-remote.sh" ] || die "run-batch: missing $stagedir/run-remote.sh"
    658   [ -n "$resultsdir" ] || die "run-batch: results dir required"
    659   command -v tar >/dev/null 2>&1 || die "run-batch: tar not found on host"
    660   mkdir -p "$resultsdir"
    661   # shellcheck disable=SC2207
    662   args=($(ssh_args "$arch"))
    663   tar -C "$stagedir" -cf - . | ssh "${args[@]}" "$SSH_USER@127.0.0.1" \
    664     'd=$(mktemp -d /tmp/kit-vm.XXXXXX) || exit 1; tar -C "$d" -xf - || exit 1; ( cd "$d" && sh run-remote.sh ) </dev/null >/dev/null 2>&1; tar -C "$d/res" -cf - . 2>/dev/null; rm -rf "$d"' \
    665     | tar -C "$resultsdir" -xf - 2>/dev/null || true
    666 }
    667 
    668 doctor() {
    669   printf 'host: %s/%s\n' "$(uname -s 2>/dev/null)" "$(uname -m 2>/dev/null)"
    670   printf 'vm root: %s\n' "$VM_ROOT"
    671   printf 'download cache: %s\n' "$DL_ROOT"
    672   for tool in curl shasum xz hdiutil ssh-keygen ssh qemu-img \
    673               qemu-system-x86_64 qemu-system-aarch64 qemu-system-riscv64; do
    674     if command -v "$tool" >/dev/null 2>&1; then
    675       printf '  OK      %s (%s)\n' "$tool" "$(command -v "$tool")"
    676     else
    677       printf '  MISSING %s\n' "$tool"
    678     fi
    679   done
    680   printf 'ssh key: %s\n' "$([ -f "$SSH_KEY" ] && echo "$SSH_KEY" || echo "$SSH_KEY (absent)")"
    681   for arch in amd64 aarch64 riscv64; do
    682     printf '  %-7s cloudinit=%s ssh_port=%s golden=%s\n' \
    683       "$arch" \
    684       "$(cloudinit_supported "$arch" && echo yes || echo no)" \
    685       "$(ssh_port "$arch")" \
    686       "$([ -f "$(golden_cache_path "$arch")" ] && echo cached || echo none)"
    687   done
    688 }
    689 
    690 cmd="${1:-}"
    691 case "$cmd" in
    692   doctor) doctor ;;
    693   fetch) [ $# -eq 2 ] || { usage; exit 2; }; fetch_arch "$2" ;;
    694   prepare) [ $# -eq 2 ] || { usage; exit 2; }; prepare_arch "$2" ;;
    695   seed) [ $# -eq 2 ] || { usage; exit 2; }; seed_arch "$2" ;;
    696   provision) [ $# -eq 2 ] || { usage; exit 2; }; provision_arch "$2" ;;
    697   run) [ $# -ge 2 ] || { usage; exit 2; }; arch="$2"; shift 2; run_arch "$arch" "$@" ;;
    698   wait-ssh) [ $# -eq 2 ] || { usage; exit 2; }; wait_ssh "$2" ;;
    699   ssh) [ $# -ge 2 ] || { usage; exit 2; }; arch="$2"; shift 2; ssh_arch "$arch" "$@" ;;
    700   scp) [ $# -eq 4 ] || { usage; exit 2; }; scp_arch "$2" "$3" "$4" ;;
    701   run-batch) [ $# -eq 4 ] || { usage; exit 2; }; run_batch_arch "$2" "$3" "$4" ;;
    702   -h|--help|help|"") usage ;;
    703   *) usage; exit 2 ;;
    704 esac