windows_vm.sh (40504B)
1 #!/usr/bin/env bash 2 # Provision and drive a Windows 11 ARM64 VM under QEMU+hvf on Apple Silicon, and 3 # run kit-produced Windows executables inside it over SSH. 4 # 5 # On Apple Silicon there is no hardware acceleration for x86_64, so a single 6 # hvf-accelerated Windows 11 ARM64 VM serves both Windows targets: aarch64-windows 7 # binaries run natively and x86_64-windows binaries run via the in-box x64 8 # emulator (Prism). The `run x64` and `run aarch64` paths therefore target the 9 # same VM. 10 # 11 # The Windows install media is supplied by the user (see KIT_WINDOWS_ISO). The 12 # virtio-win network driver and OpenSSH server are fetched/bundled and installed 13 # unattended, so first boot needs no Windows Update and no interactive setup. 14 15 set -eu 16 17 ROOT="$(cd "$(dirname "$0")/.." && pwd)" 18 TEMPLATE_DIR="$ROOT/scripts/win" 19 VM_ROOT="${KIT_WINDOWS_VM_DIR:-$ROOT/build/windows-vm}" 20 XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" 21 CACHE_ROOT="${KIT_WINDOWS_CACHE_DIR:-$XDG_CACHE_HOME/kit}" 22 QEMU_SHARE="${KIT_QEMU_SHARE:-/opt/homebrew/share/qemu}" 23 QEMU_BIN="${KIT_WINDOWS_QEMU:-qemu-system-aarch64}" 24 25 # Guest / VM settings. 26 SSH_USER="${KIT_WINDOWS_SSH_USER:-kit}" 27 SSH_PASS="${KIT_WINDOWS_SSH_PASS:-kit}" 28 VM_PORT="${KIT_WINDOWS_VM_PORT:-2227}" 29 VM_MEM="${KIT_WINDOWS_MEM:-6144}" 30 VM_CPUS="${KIT_WINDOWS_CPUS:-4}" 31 VM_DISK_SIZE="${KIT_WINDOWS_DISK_SIZE:-64G}" 32 # QEMU display backend. Headless (none) for automated install/boot + screendump; 33 # the console commands switch this to a visible window (cocoa on macOS) so the 34 # install can be driven interactively. 35 DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-none}" 36 WIN_EDITION="${KIT_WINDOWS_EDITION:-Windows 11 Pro}" 37 # Generic Windows 11 Pro install key (no activation; lets Setup skip the key UI). 38 PRODUCT_KEY="${KIT_WINDOWS_PRODUCT_KEY:-VK7JG-NPHTM-C97JM-9MPGT-3V66T}" 39 40 # Pinned guest helpers. 41 VIRTIO_VERSION="${KIT_VIRTIO_WIN_VERSION:-0.1.266}" 42 VIRTIO_SHA256="${KIT_VIRTIO_WIN_SHA256:-57b0f6dc8dc92dc2ae8621f8b1bfbd8a873de9bedc788c4c4b305ea28acc77cd}" 43 OPENSSH_VERSION="${KIT_OPENSSH_VERSION:-10.0.0.0p2-Preview}" 44 OPENSSH_SHA256="${KIT_OPENSSH_SHA256:-698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42}" 45 46 POWERSHELL="${KIT_WINDOWS_VM_POWERSHELL:-powershell.exe}" 47 48 # Durable VM state lives in the cache dir so `make clean` (which wipes build/) 49 # cannot destroy it: the disks, UEFI vars, seed ISO, and — critically — the SSH 50 # key the golden disk's authorized_keys is baked to. Only ephemeral per-run state 51 # (pidfile, QMP socket, logs, screenshots) lives under build/. 52 CACHE_VM="${KIT_WINDOWS_VM_CACHE:-$CACHE_ROOT/windows-vm}" 53 DISK_PATH="$CACHE_VM/win11-arm64.qcow2" 54 # Durable "golden" disk: a fully-installed, bootstrapped, cleanly-shut-down image 55 # so the long install runs once; the working DISK_PATH is restored from it cheaply. 56 GOLDEN_PATH="${KIT_WINDOWS_GOLDEN:-$CACHE_VM/win11-arm64-golden.qcow2}" 57 PROVISIONED_MARKER="$CACHE_VM/win11-arm64.provisioned" 58 VARS_PATH="$CACHE_VM/aarch64-vars.fd" 59 SEED_PATH="$CACHE_VM/kit-seed.iso" 60 SSH_KEY_DEFAULT="$CACHE_VM/ssh/id_ed25519" 61 QMP_SOCK="$VM_ROOT/run/qmp.sock" 62 PID_FILE="$VM_ROOT/run/qemu.pid" 63 QEMU_LOG="$VM_ROOT/run/qemu.log" 64 SHOT_DIR="$VM_ROOT/screenshots" 65 66 usage() { 67 cat <<EOF 68 usage: scripts/windows_vm.sh <command> [args...] 69 70 provisioning (single Windows 11 ARM64 VM, serves both arches): 71 doctor print tool / firmware / media / VM status 72 fetch-virtio download + verify the pinned virtio-win driver ISO 73 fetch-openssh download + verify the pinned Win32-OpenSSH ARM64 zip 74 seed (re)build the unattended seed ISO (autounattend + bootstrap) 75 prepare restore golden disk if cached, else stage for install 76 install unattended install once, then cache the golden disk 77 console-install interactive install in a QEMU window (you drive Setup); 78 keeps the seed unless KIT_WINDOWS_NO_SEED=1 79 console open a window on the installed VM (manual inspection) 80 firstboot finish a fresh install: boot it (no install CD) so the 81 first-logon bootstrap runs, then cache the golden disk 82 boot boot the installed VM headless in the background 83 wait-ssh wait until the VM answers SSH, then print guest info 84 ssh [cmd...] ssh into the running VM 85 screenshot [name] capture the VM framebuffer to build/windows-vm/screenshots 86 snapshot cache the current (stopped) disk as the golden disk 87 reset restore the working disk from the golden disk 88 stop power down the running VM 89 90 execution (used by the COFF/PE smoke tests): 91 smoke <arch> run a small probe in the VM 92 run <arch> exe [args] upload exe to the VM, run it, then remove it 93 run-batch <arch> <dir> <res> 94 upload a staging dir's *.exe + run-remote.ps1, run it, 95 and bring per-binary res\<id>.{rc,out,err} back into 96 <res> (used by test/lib/exec_vm.sh) 97 98 arches: x64 | x86_64 | amd64 | aarch64 | arm64 | aa64 99 100 env: 101 KIT_WINDOWS_ISO path to the Windows 11 ARM64 install ISO (required to 102 install; else auto-discovered in $CACHE_ROOT) 103 KIT_WINDOWS_VM_PORT host SSH port forwarded to the VM (default $VM_PORT) 104 KIT_WINDOWS_MEM/CPUS guest memory MiB / vcpus (default $VM_MEM / $VM_CPUS) 105 KIT_WINDOWS_VM_X64 SSH destination override for the x64 lane 106 KIT_WINDOWS_VM_AARCH64 SSH destination override for the arm64 lane 107 KIT_WINDOWS_VM_*_PORT optional SSH port overrides 108 KIT_WINDOWS_VM_SSH_KEY SSH private key (default $SSH_KEY_DEFAULT) 109 KIT_WINDOWS_VM_SSH_OPTS extra ssh options 110 KIT_WINDOWS_VM_KEEP keep uploaded temp dirs when set non-empty 111 EOF 112 } 113 114 die() { 115 printf 'windows-vm: %s\n' "$*" >&2 116 exit 1 117 } 118 119 canon_arch() { 120 case "${1:-}" in 121 x64|x86_64|amd64) echo x64 ;; 122 aarch64|arm64|aa64) echo aarch64 ;; 123 *) die "unknown arch '${1:-}'" ;; 124 esac 125 } 126 127 # --------------------------------------------------------------------------- 128 # media discovery + fetch 129 # --------------------------------------------------------------------------- 130 131 sha256_file() { 132 if command -v shasum >/dev/null 2>&1; then 133 shasum -a 256 "$1" | awk '{ print $1 }' 134 elif command -v sha256sum >/dev/null 2>&1; then 135 sha256sum "$1" | awk '{ print $1 }' 136 else 137 die "missing shasum or sha256sum" 138 fi 139 } 140 141 verify_sha256() { 142 local path=$1 expect=$2 got 143 [ -n "$expect" ] || return 0 144 got="$(sha256_file "$path")" 145 [ "$got" = "$expect" ] || 146 die "checksum mismatch for $(basename "$path"): got $got want $expect" 147 } 148 149 win_iso() { 150 if [ -n "${KIT_WINDOWS_ISO:-}" ]; then 151 printf '%s\n' "$KIT_WINDOWS_ISO" 152 return 0 153 fi 154 local d 155 for d in "$CACHE_ROOT"/Win11*[Aa]rm64*.iso "$CACHE_ROOT"/*[Aa]rm64*.iso; do 156 [ -f "$d" ] && { printf '%s\n' "$d"; return 0; } 157 done 158 return 1 159 } 160 161 virtio_iso() { 162 if [ -n "${KIT_VIRTIO_WIN_ISO:-}" ]; then 163 printf '%s\n' "$KIT_VIRTIO_WIN_ISO" 164 else 165 printf '%s\n' "$CACHE_ROOT/virtio-win/virtio-win-$VIRTIO_VERSION.iso" 166 fi 167 } 168 169 openssh_zip() { 170 if [ -n "${KIT_OPENSSH_ZIP:-}" ]; then 171 printf '%s\n' "$KIT_OPENSSH_ZIP" 172 else 173 printf '%s\n' "$CACHE_ROOT/openssh/OpenSSH-ARM64-$OPENSSH_VERSION.zip" 174 fi 175 } 176 177 fetch_virtio() { 178 local out url 179 out="$(virtio_iso)" 180 if [ -f "$out" ]; then 181 verify_sha256 "$out" "$VIRTIO_SHA256" 182 printf 'virtio-win present: %s\n' "$out" 183 return 0 184 fi 185 command -v curl >/dev/null 2>&1 || die "curl not found" 186 url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-$VIRTIO_VERSION-1/virtio-win-$VIRTIO_VERSION.iso" 187 mkdir -p "$(dirname "$out")" 188 printf 'download: %s\n' "$url" 189 curl -fL --retry 3 --retry-delay 2 -o "$out.part" "$url" 190 mv "$out.part" "$out" 191 verify_sha256 "$out" "$VIRTIO_SHA256" 192 printf 'verified: %s\n' "$out" 193 } 194 195 fetch_openssh() { 196 local out url 197 out="$(openssh_zip)" 198 if [ -f "$out" ]; then 199 verify_sha256 "$out" "$OPENSSH_SHA256" 200 printf 'openssh present: %s\n' "$out" 201 return 0 202 fi 203 command -v curl >/dev/null 2>&1 || die "curl not found" 204 url="https://github.com/PowerShell/Win32-OpenSSH/releases/download/$OPENSSH_VERSION/OpenSSH-ARM64.zip" 205 mkdir -p "$(dirname "$out")" 206 printf 'download: %s\n' "$url" 207 curl -fL --retry 3 --retry-delay 2 -o "$out.part" "$url" 208 mv "$out.part" "$out" 209 verify_sha256 "$out" "$OPENSSH_SHA256" 210 printf 'verified: %s\n' "$out" 211 } 212 213 # --------------------------------------------------------------------------- 214 # provisioning artifacts 215 # --------------------------------------------------------------------------- 216 217 ensure_ssh_key() { 218 local key="${KIT_WINDOWS_VM_SSH_KEY:-$SSH_KEY_DEFAULT}" 219 if [ ! -f "$key" ]; then 220 mkdir -p "$(dirname "$key")" 221 ssh-keygen -q -t ed25519 -N "" -C "kit-windows-vm" -f "$key" 222 fi 223 printf '%s\n' "$key" 224 } 225 226 create_disk() { 227 command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found" 228 mkdir -p "$(dirname "$DISK_PATH")" 229 if [ -f "$DISK_PATH" ]; then 230 printf 'disk already present: %s\n' "$DISK_PATH" 231 else 232 qemu-img create -f qcow2 "$DISK_PATH" "$VM_DISK_SIZE" >/dev/null 233 printf 'disk created: %s (%s)\n' "$DISK_PATH" "$VM_DISK_SIZE" 234 fi 235 } 236 237 # Save the cleanly-shut-down working disk to the durable golden cache. 238 cache_golden() { 239 vm_running && die "stop the VM before caching the golden disk" 240 [ -f "$DISK_PATH" ] || die "no working disk to cache: $DISK_PATH" 241 command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found" 242 mkdir -p "$(dirname "$GOLDEN_PATH")" 243 printf 'cache golden disk: %s -> %s\n' "$DISK_PATH" "$GOLDEN_PATH" 244 qemu-img convert -O qcow2 -c "$DISK_PATH" "$GOLDEN_PATH.tmp" 245 mv "$GOLDEN_PATH.tmp" "$GOLDEN_PATH" 246 touch "$PROVISIONED_MARKER" 247 printf 'golden cached. boot with: scripts/windows_vm.sh boot\n' 248 } 249 250 # Restore the working disk from the golden cache (cheap; no reinstall). 251 restore_golden() { 252 [ -f "$GOLDEN_PATH" ] || die "no golden disk cached: $GOLDEN_PATH (run install first)" 253 vm_running && die "stop the VM before restoring the golden disk" 254 command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found" 255 mkdir -p "$(dirname "$DISK_PATH")" 256 printf 'restore golden disk: %s -> %s\n' "$GOLDEN_PATH" "$DISK_PATH" 257 qemu-img convert -O qcow2 "$GOLDEN_PATH" "$DISK_PATH.tmp" 258 mv "$DISK_PATH.tmp" "$DISK_PATH" 259 touch "$PROVISIONED_MARKER" 260 } 261 262 firmware_code() { printf '%s\n' "$QEMU_SHARE/edk2-aarch64-code.fd"; } 263 firmware_vars_template() { printf '%s\n' "$QEMU_SHARE/edk2-arm-vars.fd"; } 264 265 ensure_firmware_vars() { 266 local code tmpl 267 code="$(firmware_code)" 268 tmpl="$(firmware_vars_template)" 269 [ -f "$code" ] || die "missing QEMU firmware code: $code" 270 [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl" 271 mkdir -p "$(dirname "$VARS_PATH")" 272 # Keep the vars file across boots: it holds the "Windows Boot Manager" NVRAM 273 # entry created during install. Only seed it from the pristine template when 274 # it does not exist yet. 275 if [ ! -f "$VARS_PATH" ]; then 276 cp "$tmpl" "$VARS_PATH" 277 fi 278 } 279 280 # Reset UEFI NVRAM to the pristine template. Used at the start of an install so a 281 # prior attempt's stale boot entries (e.g. a "Windows Boot Manager" pointing at a 282 # now-wiped GPT partition) cannot derail the boot order; the install itself 283 # writes the correct entry, which then persists for later boots. 284 reset_firmware_vars() { 285 local code tmpl 286 code="$(firmware_code)" 287 tmpl="$(firmware_vars_template)" 288 [ -f "$code" ] || die "missing QEMU firmware code: $code" 289 [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl" 290 mkdir -p "$(dirname "$VARS_PATH")" 291 cp "$tmpl" "$VARS_PATH" 292 } 293 294 build_seed() { 295 local key pub seeddir 296 command -v hdiutil >/dev/null 2>&1 || die "hdiutil not found (macOS host required)" 297 key="$(ensure_ssh_key)" 298 pub="$(cat "$key.pub")" 299 fetch_virtio >/dev/null 300 fetch_openssh >/dev/null 301 [ -r "$TEMPLATE_DIR/autounattend.xml.in" ] || die "missing $TEMPLATE_DIR/autounattend.xml.in" 302 [ -r "$TEMPLATE_DIR/bootstrap.ps1.in" ] || die "missing $TEMPLATE_DIR/bootstrap.ps1.in" 303 304 mkdir -p "$CACHE_VM" 305 seeddir="$CACHE_VM/seed-contents" 306 rm -rf "$seeddir" 307 mkdir -p "$seeddir/kit" 308 309 # autounattend.xml lives at the media root so Windows Setup auto-detects it. 310 sed -e "s|@WIN_EDITION@|$WIN_EDITION|g" \ 311 -e "s|@PRODUCT_KEY@|$PRODUCT_KEY|g" \ 312 -e "s|@KIT_USER@|$SSH_USER|g" \ 313 -e "s|@KIT_PASS@|$SSH_PASS|g" \ 314 "$TEMPLATE_DIR/autounattend.xml.in" > "$seeddir/autounattend.xml" 315 316 # bootstrap.ps1 + the OpenSSH bundle live under \kit on the seed media. 317 # Substitute the public key via a temp file to keep slashes/+ literal. 318 awk -v pub="$pub" -v user="$SSH_USER" ' 319 { gsub(/@SSH_PUBKEY@/, pub); gsub(/@KIT_USER@/, user); print } 320 ' "$TEMPLATE_DIR/bootstrap.ps1.in" > "$seeddir/kit/bootstrap.ps1" 321 cp "$(openssh_zip)" "$seeddir/kit/OpenSSH-ARM64.zip" 322 323 rm -f "$SEED_PATH" 324 hdiutil makehybrid -quiet -iso -joliet -default-volume-name KITSEED \ 325 -o "$SEED_PATH" "$seeddir" 326 printf 'seed ready: %s\n' "$SEED_PATH" 327 } 328 329 prepare() { 330 ensure_firmware_vars 331 ensure_ssh_key >/dev/null 332 if [ -f "$GOLDEN_PATH" ]; then 333 restore_golden 334 printf 'prepared from golden disk. boot with: scripts/windows_vm.sh boot\n' 335 else 336 create_disk 337 build_seed 338 printf 'prepared for install. install with: scripts/windows_vm.sh install\n' 339 fi 340 } 341 342 # --------------------------------------------------------------------------- 343 # QMP control (python handles the capabilities handshake) 344 # --------------------------------------------------------------------------- 345 346 qmp() { 347 # qmp <json-command> [<json-command> ...] : run commands over the QMP socket. 348 [ -S "$QMP_SOCK" ] || { printf 'windows-vm: QMP socket not present (%s)\n' "$QMP_SOCK" >&2; return 1; } 349 python3 - "$QMP_SOCK" "$@" <<'PY' 350 import socket, sys, json 351 sock = sys.argv[1] 352 cmds = sys.argv[2:] 353 s = socket.socket(socket.AF_UNIX) 354 s.settimeout(10) 355 s.connect(sock) 356 f = s.makefile('rwb', buffering=0) 357 f.readline() # greeting 358 f.write(b'{"execute":"qmp_capabilities"}\n'); f.readline() 359 for c in cmds: 360 f.write((c + "\n").encode()) 361 print(f.readline().decode().strip()) 362 PY 363 } 364 365 screenshot() { 366 local name="${1:-shot}" base ppm png 367 mkdir -p "$SHOT_DIR" 368 base="$SHOT_DIR/$name" 369 png="$base.png" 370 ppm="$base.ppm" 371 rm -f "$png" "$ppm" 372 # QEMU 6+ supports PNG output directly; fall back to PPM + pnmtopng. 373 if qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$png\",\"format\":\"png\"}}" 2>/dev/null \ 374 | grep -q '"return"' && [ -s "$png" ]; then 375 printf '%s\n' "$png" 376 return 0 377 fi 378 qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$ppm\"}}" >/dev/null 2>&1 || true 379 if [ -s "$ppm" ] && command -v pnmtopng >/dev/null 2>&1; then 380 pnmtopng "$ppm" > "$png" 2>/dev/null && { rm -f "$ppm"; printf '%s\n' "$png"; return 0; } 381 fi 382 [ -s "$ppm" ] && { printf '%s\n' "$ppm"; return 0; } 383 return 1 384 } 385 386 vm_running() { 387 # True if the background VM (pidfile) or an interactive console VM (foreground, 388 # no pidfile) is running. The process check keeps `snapshot`/`stop` safe after 389 # an interactive `console-install`, which launches QEMU without a pidfile. 390 # Anchor on argv[0] (`^kit-qemu-win`) so this matches only the QEMU process, 391 # not management scripts whose command line happens to mention "kit-qemu-win". 392 if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then return 0; fi 393 pgrep -f '^kit-qemu-win' >/dev/null 2>&1 394 } 395 396 guest_shutdown() { 397 # Clean ACPI shutdown so the golden disk is not crash-consistent (avoids a 398 # chkdsk/repair pass on next boot). Prefer an in-guest shutdown, fall back to 399 # the ACPI power button over QMP. 400 local i 401 ssh_setup aarch64 2>/dev/null && \ 402 ssh "${SSH_ARGS[@]}" "$SSH_DEST" "shutdown /s /t 0 /f" >/dev/null 2>&1 || true 403 qmp '{"execute":"system_powerdown"}' >/dev/null 2>&1 || true 404 for i in $(seq 1 120); do 405 vm_running || { rm -f "$PID_FILE"; return 0; } 406 sleep 2 407 done 408 return 1 409 } 410 411 powerdown() { 412 if vm_running; then 413 qmp '{"execute":"system_powerdown"}' >/dev/null 2>&1 || true 414 printf 'sent ACPI powerdown; waiting for shutdown...\n' 415 local i 416 for i in $(seq 1 60); do 417 vm_running || { printf 'vm stopped\n'; rm -f "$PID_FILE"; return 0; } 418 sleep 2 419 done 420 printf 'still running; killing\n' 421 kill "$(cat "$PID_FILE")" 2>/dev/null || true 422 else 423 printf 'no running vm\n' 424 fi 425 rm -f "$PID_FILE" 426 } 427 428 # --------------------------------------------------------------------------- 429 # QEMU launch 430 # --------------------------------------------------------------------------- 431 432 base_qemu_args() { 433 # Emits the device set shared by install and normal boots. 434 local code 435 code="$(firmware_code)" 436 QEMU_ARGS=( 437 -name kit-win11-arm64 438 -L "$QEMU_SHARE" 439 -machine virt 440 -accel hvf 441 -cpu host 442 -m "$VM_MEM" 443 -smp "$VM_CPUS" 444 -drive "if=pflash,format=raw,readonly=on,file=$code" 445 -drive "if=pflash,format=raw,file=$VARS_PATH" 446 -device ramfb 447 -device "qemu-xhci,id=xhci" 448 -device usb-kbd 449 -device usb-tablet 450 -nic "user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:$VM_PORT-:22" 451 -drive "if=none,id=sysdisk,file=$DISK_PATH,format=qcow2,cache=writeback,discard=unmap" 452 -device "nvme,drive=sysdisk,serial=kitwin,bootindex=0" 453 -display "$DISPLAY_BACKEND" 454 -qmp "unix:$QMP_SOCK,server,nowait" 455 -serial "file:$VM_ROOT/run/serial.log" 456 ) 457 } 458 459 launch_qemu() { 460 # launch_qemu : start QEMU in the background using the assembled QEMU_ARGS. 461 command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH" 462 mkdir -p "$VM_ROOT/run" 463 rm -f "$QMP_SOCK" 464 : > "$QEMU_LOG" 465 # Launch under a distinct argv[0] so a broad `pkill -f qemu-system-aarch64` 466 # (e.g. from a concurrent FreeBSD VM workflow sharing this repo) cannot match 467 # and kill this VM. QEMU resolves its data dir from the real executable path, 468 # not argv[0], and we pass -L explicitly, so the rename is invisible to QEMU. 469 ( exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}" ) >>"$QEMU_LOG" 2>&1 & 470 echo $! > "$PID_FILE" 471 printf 'qemu pid %s; log %s\n' "$(cat "$PID_FILE")" "$QEMU_LOG" 472 # Wait for the QMP socket so callers can drive the monitor. 473 local i 474 for i in $(seq 1 30); do 475 [ -S "$QMP_SOCK" ] && return 0 476 vm_running || { tail -20 "$QEMU_LOG" >&2; die "qemu exited during startup"; } 477 sleep 1 478 done 479 die "QMP socket did not appear" 480 } 481 482 # Attach the install + virtio + seed media as USB mass-storage (in-box usbstor 483 # driver) so UEFI can boot the installer and Setup can read the seed + drivers. 484 # The system NVMe disk is bootindex=0; the install CD is bootindex=1 so the empty 485 # disk falls through to the CD on the first boot, but once Setup writes the 486 # Windows Boot Manager to NVMe every later reboot boots the installed OS instead 487 # of looping back into the installer. 488 attach_install_media() { 489 local iso="$1" virtio="$2" seed="${3:-}" 490 QEMU_ARGS+=( 491 -device "usb-storage,drive=installcd,bootindex=1" 492 -drive "if=none,id=installcd,format=raw,media=cdrom,readonly=on,file=$iso" 493 -device "usb-storage,drive=virtiocd" 494 -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio" 495 ) 496 [ -n "$seed" ] && QEMU_ARGS+=( 497 -device "usb-storage,drive=seedcd" 498 -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$seed" 499 ) 500 } 501 502 install_vm() { 503 local iso virtio seed code 504 iso="$(win_iso)" || die "no Windows ISO; set KIT_WINDOWS_ISO or place Win11*Arm64*.iso in $CACHE_ROOT" 505 [ -f "$iso" ] || die "Windows ISO not found: $iso" 506 vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first" 507 if [ -f "$GOLDEN_PATH" ]; then 508 die "golden disk already exists ($GOLDEN_PATH); 'boot' to use it, 'reset' to revert to it, or remove it to reinstall" 509 fi 510 511 # Start each install from a fresh blank disk: a prior attempt may have left a 512 # half-installed disk, and autounattend wipes disk 0 regardless. 513 rm -f "$DISK_PATH" 514 create_disk 515 reset_firmware_vars 516 build_seed 517 virtio="$(virtio_iso)" 518 seed="$SEED_PATH" 519 [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)" 520 521 base_qemu_args 522 attach_install_media "$iso" "$virtio" "$seed" 523 524 printf 'installing Windows from: %s\n' "$iso" 525 printf ' edition : %s\n' "$WIN_EDITION" 526 printf ' virtio : %s\n' "$virtio" 527 printf ' seed : %s\n' "$seed" 528 printf ' ssh : %s@127.0.0.1:%s\n' "$SSH_USER" "$VM_PORT" 529 launch_qemu 530 531 # Dismiss the one-time "Press any key to boot from CD or DVD" prompt. Send Enter 532 # only until Setup starts writing the NVMe disk (proof it booted past the 533 # prompt), then stop — otherwise a later Enter lands on the install-progress 534 # screen's Cancel button and pops a "quit?" dialog. Watching disk growth makes 535 # this robust to a slow firmware POST without overshooting into Setup's UI. 536 ( local i sz 537 for i in $(seq 1 180); do 538 sz=$(stat -f %z "$DISK_PATH" 2>/dev/null || echo 0) 539 [ "$sz" -gt 2000000 ] && break 540 qmp '{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}' >/dev/null 2>&1 || true 541 sleep 2 542 done ) & 543 544 # Phase 1 (apply): Setup writes the image to the NVMe disk, then reboots and 545 # loops on the install CD. Detect "image applied + settled" by the disk size 546 # leveling off, then stop and hand off to firstboot_vm (which boots the 547 # installed OS without the install CD). 548 printf 'applying the Windows image (long); switching to first boot when done...\n' 549 local cur=0 last=-1 stable=0 i=0 550 while [ $i -lt 3600 ]; do 551 vm_running || die "qemu exited during image apply (see $QEMU_LOG)" 552 cur=$(stat -f %z "$DISK_PATH" 2>/dev/null || echo 0) 553 if [ "$cur" -gt 9000000000 ]; then 554 if [ "$cur" -eq "$last" ]; then stable=$((stable + 1)); else stable=0; fi 555 [ "$stable" -ge 3 ] && break 556 fi 557 last=$cur 558 [ $((i % 120)) -eq 0 ] && screenshot "apply-$i" >/dev/null 2>&1 || true 559 sleep 20 560 i=$((i + 20)) 561 done 562 [ "$cur" -gt 9000000000 ] || 563 die "image apply did not complete (disk only $cur bytes; see $SHOT_DIR and $QEMU_LOG)" 564 printf 'image applied (%s bytes on disk); stopping apply phase\n' "$cur" 565 kill "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null || true 566 for _ in $(seq 1 30); do vm_running || break; sleep 1; done 567 rm -f "$PID_FILE" 568 firstboot_vm 569 } 570 571 boot_vm() { 572 vm_running && { printf 'vm already running (pid %s)\n' "$(cat "$PID_FILE")"; return 0; } 573 if [ ! -f "$DISK_PATH" ]; then 574 [ -f "$GOLDEN_PATH" ] || die "no installed disk and no golden cache; run install first" 575 restore_golden 576 fi 577 ensure_firmware_vars 578 base_qemu_args 579 launch_qemu 580 printf 'booted installed VM (pid %s)\n' "$(cat "$PID_FILE")" 581 } 582 583 # Interactive install: open a real QEMU window and let the user drive Setup 584 # (press a key at the boot prompt, click through any screens). Runs in the 585 # foreground. The autounattend seed is still attached by default so the kit user, 586 # OpenSSH, virtio NIC, and SSH key are configured automatically; set 587 # KIT_WINDOWS_NO_SEED=1 for a fully manual install. When Windows is installed and 588 # shut down, run `snapshot` to cache it as the golden disk. 589 console_install() { 590 local iso virtio seed="" 591 iso="$(win_iso)" || die "no Windows ISO; set KIT_WINDOWS_ISO or place Win11*Arm64*.iso in $CACHE_ROOT" 592 [ -f "$iso" ] || die "Windows ISO not found: $iso" 593 vm_running && die "the headless VM is running (pid $(cat "$PID_FILE")); stop it first: scripts/windows_vm.sh stop" 594 if [ -f "$GOLDEN_PATH" ]; then 595 die "golden disk already exists ($GOLDEN_PATH); 'console' boots it, 'reset' reverts to it, or remove it to reinstall" 596 fi 597 command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH" 598 rm -f "$DISK_PATH" 599 create_disk 600 reset_firmware_vars 601 virtio="$(virtio_iso)" 602 [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)" 603 if [ -z "${KIT_WINDOWS_NO_SEED:-}" ]; then build_seed; seed="$SEED_PATH"; fi 604 DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-cocoa}" 605 mkdir -p "$VM_ROOT/run" 606 rm -f "$QMP_SOCK" 607 base_qemu_args 608 attach_install_media "$iso" "$virtio" "$seed" 609 cat <<MSG 610 Interactive Windows install — a QEMU window will open. 611 * Press a key at "Press any key to boot from CD or DVD" to boot the installer.$( 612 [ -n "$seed" ] && printf '\n * The autounattend seed then drives Setup (kit user, OpenSSH, virtio NIC, SSH key).' \ 613 || printf '\n * No seed attached: install Windows manually; SSH must be set up afterwards.') 614 * When Windows reaches the desktop, shut it down (Start > Power > Shut down). 615 * Then cache it as the golden disk: scripts/windows_vm.sh snapshot 616 MSG 617 exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}" 618 } 619 620 # Open a window on the installed VM for manual inspection / interactive driving. 621 # Runs in the foreground (cocoa). Before the install is finalized (no golden disk 622 # yet) it boots like a first boot: reset NVRAM (so the firmware enumerates the 623 # populated disk and boots Windows' ARM fallback loader) and attach the virtio + 624 # seed media so the first-logon bootstrap can run or be inspected. Once a golden 625 # disk exists it boots clean (no CDs, keeping NVRAM). 626 console_boot() { 627 vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first" 628 command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH" 629 DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-cocoa}" 630 mkdir -p "$VM_ROOT/run" 631 rm -f "$QMP_SOCK" 632 if [ -f "$GOLDEN_PATH" ] || [ -f "$PROVISIONED_MARKER" ]; then 633 [ -f "$DISK_PATH" ] || restore_golden 634 ensure_firmware_vars 635 base_qemu_args 636 printf 'Opening a window on the installed VM. Close it or shut down the guest to exit.\n' 637 else 638 [ -f "$DISK_PATH" ] || die "no installed disk; run console-install first" 639 local virtio 640 virtio="$(virtio_iso)" 641 [ -f "$SEED_PATH" ] || build_seed 642 reset_firmware_vars 643 base_qemu_args 644 [ -f "$virtio" ] && QEMU_ARGS+=( 645 -device "usb-storage,drive=virtiocd" 646 -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio" 647 ) 648 QEMU_ARGS+=( 649 -device "usb-storage,drive=seedcd" 650 -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$SEED_PATH" 651 ) 652 printf 'Opening a window on the (not-yet-finalized) VM: fresh NVRAM + virtio/seed attached.\n' 653 printf 'Diagnostics inside the guest: C:\\kit-bootstrap.log, C:\\kit-ready.txt, services.msc (sshd), firewall.\n' 654 fi 655 exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}" 656 } 657 658 # First boot of a freshly-installed disk. The Windows installer applies the image 659 # and then reboots; if the bootable install CD is still in the boot path the 660 # emulated firmware loops back into Setup instead of booting the new OS (its NVRAM 661 # "Windows Boot Manager" entry does not persist under QEMU). So we reset NVRAM 662 # (forcing a fresh enumeration that finds Windows' ARM fallback loader, 663 # \EFI\Boot\bootaa64.efi) and boot WITHOUT the install CD but WITH the virtio + 664 # seed media, so the FirstLogon bootstrap can install the NIC driver + OpenSSH. 665 # On success the disk is cleanly shut down and cached as the golden disk. 666 firstboot_vm() { 667 local virtio 668 vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first" 669 [ -f "$DISK_PATH" ] || die "no installed disk; run install or console-install first" 670 [ -f "$GOLDEN_PATH" ] && die "golden disk already exists ($GOLDEN_PATH); use boot/reset" 671 virtio="$(virtio_iso)" 672 [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)" 673 [ -f "$SEED_PATH" ] || build_seed 674 reset_firmware_vars 675 base_qemu_args 676 QEMU_ARGS+=( 677 -device "usb-storage,drive=virtiocd" 678 -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio" 679 -device "usb-storage,drive=seedcd" 680 -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$SEED_PATH" 681 ) 682 printf 'first boot (no install CD): finishing setup + first-logon bootstrap...\n' 683 launch_qemu 684 wait_ssh 2400 685 printf 'bootstrap complete; shutting down cleanly to cache the golden disk...\n' 686 if guest_shutdown; then 687 cache_golden 688 else 689 printf 'WARNING: clean shutdown timed out; golden NOT cached.\n' 690 printf 'Run: scripts/windows_vm.sh stop && scripts/windows_vm.sh snapshot\n' 691 fi 692 } 693 694 # --------------------------------------------------------------------------- 695 # SSH plumbing (also used by run/smoke) 696 # --------------------------------------------------------------------------- 697 698 vm_dest() { 699 case "$(canon_arch "$1")" in 700 x64) 701 if [ -n "${KIT_WINDOWS_VM_X64:-${KIT_WINDOWS_VM_AMD64:-}}" ]; then 702 printf '%s\n' "${KIT_WINDOWS_VM_X64:-$KIT_WINDOWS_VM_AMD64}"; return 0 703 fi ;; 704 aarch64) 705 if [ -n "${KIT_WINDOWS_VM_AARCH64:-${KIT_WINDOWS_VM_ARM64:-}}" ]; then 706 printf '%s\n' "${KIT_WINDOWS_VM_AARCH64:-$KIT_WINDOWS_VM_ARM64}"; return 0 707 fi ;; 708 esac 709 # Fall back to the locally provisioned VM (one VM serves both arches). 710 if [ -f "$DISK_PATH" ]; then printf '%s@127.0.0.1\n' "$SSH_USER"; fi 711 } 712 713 vm_port() { 714 case "$(canon_arch "$1")" in 715 x64) [ -n "${KIT_WINDOWS_VM_X64_PORT:-${KIT_WINDOWS_VM_AMD64_PORT:-}}" ] && 716 { printf '%s\n' "${KIT_WINDOWS_VM_X64_PORT:-$KIT_WINDOWS_VM_AMD64_PORT}"; return 0; } ;; 717 aarch64) [ -n "${KIT_WINDOWS_VM_AARCH64_PORT:-${KIT_WINDOWS_VM_ARM64_PORT:-}}" ] && 718 { printf '%s\n' "${KIT_WINDOWS_VM_AARCH64_PORT:-$KIT_WINDOWS_VM_ARM64_PORT}"; return 0; } ;; 719 esac 720 if [ -f "$DISK_PATH" ]; then printf '%s\n' "$VM_PORT"; fi 721 } 722 723 ssh_setup() { 724 local arch="$1" port key opts dest 725 dest="$(vm_dest "$arch")" 726 [ -n "$dest" ] || die "no VM configured for $(canon_arch "$arch")" 727 SSH_DEST="$dest" 728 SSH_ARGS=() 729 port="$(vm_port "$arch")" 730 key="${KIT_WINDOWS_VM_SSH_KEY:-}" 731 if [ -z "$key" ] && [ -f "$SSH_KEY_DEFAULT" ]; then key="$SSH_KEY_DEFAULT"; fi 732 opts="${KIT_WINDOWS_VM_SSH_OPTS:-}" 733 if [ -n "$opts" ]; then 734 # Intentional word-splitting of a user-provided ssh option string. 735 # shellcheck disable=SC2206 736 SSH_ARGS=($opts) 737 fi 738 [ -n "$key" ] && SSH_ARGS=("${SSH_ARGS[@]}" -i "$key") 739 # Use -o Port= (not -p) so SSH_ARGS works verbatim for both ssh and scp. 740 [ -n "$port" ] && SSH_ARGS=("${SSH_ARGS[@]}" -o "Port=$port") 741 SSH_ARGS=("${SSH_ARGS[@]}" 742 -o BatchMode=yes 743 -o StrictHostKeyChecking=no 744 -o UserKnownHostsFile=/dev/null 745 -o LogLevel=ERROR 746 -o ConnectTimeout=8) 747 } 748 749 remote_ps() { 750 # Send the PowerShell program as -EncodedCommand (UTF-16LE base64). The guest's 751 # default ssh shell is cmd, and ssh flattens the remote argv into one string, so 752 # a bare -Command "<script>" lets cmd mis-parse the script's pipes/quotes (e.g. 753 # `| Out-Null`). Base64 has no shell-special characters, so it travels intact. 754 # stdin (used by the upload step) still flows through to the PowerShell process. 755 local enc full 756 # Silence the progress stream so it does not show up as CLIXML noise on stderr. 757 full='$ProgressPreference = "SilentlyContinue"; '"$1" 758 enc=$(printf '%s' "$full" | iconv -t UTF-16LE | base64 | tr -d '\n') 759 ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$POWERSHELL" -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$enc" 760 } 761 762 ps_sq() { printf '%s' "$1" | sed "s/'/''/g"; } 763 b64_arg() { printf '%s' "$1" | base64 | tr -d '\n'; } 764 765 ps_arg_array() { 766 local first=1 arg enc 767 printf '@(' 768 for arg in "$@"; do 769 enc="$(b64_arg "$arg")" 770 if [ "$first" -eq 0 ]; then printf ','; fi 771 first=0 772 printf "'%s'" "$enc" 773 done 774 printf ')' 775 } 776 777 ps_env_assignments() { 778 local names name val 779 names="${KIT_WINDOWS_VM_ENV_VARS:-KIT_WIN_PROBE}" 780 for name in $names; do 781 if [ -n "${!name+x}" ]; then 782 val="${!name}" 783 printf '$env:%s = '\''%s'\''; ' "$name" "$(ps_sq "$val")" 784 fi 785 done 786 } 787 788 remote_mkdir() { 789 local token ps 790 token="kit-vm-$(date +%Y%m%d%H%M%S)-$$-$RANDOM" 791 ps="\$ErrorActionPreference='Stop'; \$d=Join-Path \$env:TEMP '$(ps_sq "$token")'; New-Item -ItemType Directory -Force -Path \$d | Out-Null; [Console]::Out.Write(\$d)" 792 remote_ps "$ps" 793 } 794 795 remote_cleanup() { 796 local dir=$1 ps 797 [ -n "${KIT_WINDOWS_VM_KEEP:-}" ] && return 0 798 ps="\$d='$(ps_sq "$dir")'; if (Test-Path -LiteralPath \$d) { Remove-Item -LiteralPath \$d -Recurse -Force }" 799 remote_ps "$ps" >/dev/null 2>&1 || true 800 } 801 802 run_exe() { 803 local arch="$1" exe="$2" destdir base dest_fwd run_ps args_ps env_ps rc 804 shift 2 805 [ -f "$exe" ] || die "exe not found: $exe" 806 command -v ssh >/dev/null 2>&1 || die "ssh not found" 807 command -v scp >/dev/null 2>&1 || die "scp not found" 808 command -v base64 >/dev/null 2>&1 || die "base64 not found" 809 ssh_setup "$arch" 810 destdir="$(remote_mkdir)" 811 base="$(basename "$exe")" 812 # Upload over scp (Win32-OpenSSH ships the sftp subsystem). scp needs a 813 # forward-slash remote path; the run step below uses the native backslash path. 814 dest_fwd="${destdir//\\//}/$base" 815 if ! scp "${SSH_ARGS[@]}" "$exe" "$SSH_DEST:$dest_fwd" >/dev/null 2>&1; then 816 remote_cleanup "$destdir" 817 die "scp upload failed: $exe -> $dest_fwd" 818 fi 819 args_ps="$(ps_arg_array "$@")" 820 env_ps="$(ps_env_assignments)" 821 run_ps="\$ErrorActionPreference='Stop'; ${env_ps}\$exe=Join-Path '$(ps_sq "$destdir")' '$(ps_sq "$base")'; \$argv_b64=$args_ps; \$argv=@(); foreach (\$a in \$argv_b64) { \$argv += [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(\$a)) }; & \$exe @argv; \$code=\$LASTEXITCODE; if (\$null -eq \$code) { \$code=0 }; exit \$code" 822 set +e 823 remote_ps "$run_ps" 824 rc=$? 825 set -e 826 remote_cleanup "$destdir" 827 return "$rc" 828 } 829 830 # run-batch ARCH STAGEDIR RESULTSDIR 831 # Run a whole staging dir in one VM session and bring per-binary results back. 832 # Pure transport: upload the *.exe + run-remote.ps1 entry script (authored by 833 # the caller; it writes res\<id>.{rc,out,err} per binary), run it, then bring 834 # the res\ dir back, flattened into RESULTSDIR. One upload+run per arch. 835 # Mirrors run_exe's ssh/scp plumbing. The VM must already be reachable. 836 run_batch() { 837 local arch="$1" stage="$2" resultsdir="$3" destdir dest_fwd run_ps tmp 838 [ -d "$stage" ] || die "run-batch: no such staging dir: $stage" 839 [ -f "$stage/run-remote.ps1" ] || die "run-batch: missing $stage/run-remote.ps1" 840 [ -n "$resultsdir" ] || die "run-batch: results dir required" 841 command -v ssh >/dev/null 2>&1 || die "ssh not found" 842 command -v scp >/dev/null 2>&1 || die "scp not found" 843 mkdir -p "$resultsdir" 844 ssh_setup "$arch" 845 destdir="$(remote_mkdir)" 846 dest_fwd="${destdir//\\//}" 847 # Some kit-produced test exes trip a Windows Defender PUA heuristic, which 848 # blocks the launch (and would otherwise corrupt exit-code capture). Exclude 849 # the temp tree the binaries are uploaded into BEFORE the upload, so neither 850 # the on-write nor the on-execute scan quarantines them. Best-effort: requires 851 # an admin session + Defender, but works even with Tamper Protection on 852 # (path exclusions are still honored, unlike disabling real-time monitoring). 853 remote_ps "try { Add-MpPreference -ExclusionPath \$env:TEMP -ErrorAction Stop } catch {}" >/dev/null 2>&1 || true 854 # Upload only the executables + runner; the host-side bookkeeping stays home. 855 if ! scp "${SSH_ARGS[@]}" "$stage"/*.exe "$stage/run-remote.ps1" \ 856 "$SSH_DEST:$dest_fwd/" >/dev/null 2>&1; then 857 remote_cleanup "$destdir" 858 die "scp upload failed -> $dest_fwd" 859 fi 860 # Run the runner from the upload dir (it Set-Location's to $PSScriptRoot and 861 # writes res\<id>.{rc,out,err} per binary). 862 run_ps="\$ErrorActionPreference='Continue'; & (Join-Path '$(ps_sq "$destdir")' 'run-remote.ps1')" 863 remote_ps "$run_ps" >/dev/null 2>&1 || true 864 # Bring res\ back and flatten it into RESULTSDIR. 865 tmp="$(mktemp -d)" 866 scp -r "${SSH_ARGS[@]}" "$SSH_DEST:$dest_fwd/res" "$tmp/" >/dev/null 2>&1 || true 867 [ -d "$tmp/res" ] && cp "$tmp/res/"* "$resultsdir/" 2>/dev/null || true 868 rm -rf "$tmp" 869 remote_cleanup "$destdir" 870 } 871 872 smoke_arch() { 873 local arch="$1" ps 874 ssh_setup "$arch" 875 ps="\$ErrorActionPreference='Stop'; cmd.exe /c ver; [Console]::WriteLine('PROCESSOR_ARCHITECTURE=' + \$env:PROCESSOR_ARCHITECTURE); [Console]::WriteLine('PROCESSOR_ARCHITEW6432=' + \$env:PROCESSOR_ARCHITEW6432)" 876 remote_ps "$ps" 877 } 878 879 wait_ssh() { 880 local timeout="${1:-600}" arch=aarch64 i=0 shot 881 ssh_setup "$arch" 882 printf 'waiting for ssh on %s (port %s), up to %ss\n' "$SSH_DEST" "$(vm_port "$arch")" "$timeout" 883 i=0 884 while [ "$i" -lt "$timeout" ]; do 885 # Probe with a bare single-token command: Windows sshd wraps the remote 886 # command in `cmd /c "..."`, which mangles a nested `cmd /c ver` into a 887 # dangling quote. A lone builtin like `ver` round-trips cleanly. 888 if ssh "${SSH_ARGS[@]}" "$SSH_DEST" ver 2>/dev/null; then 889 printf 'ssh ready after ~%ss\n' "$i" 890 remote_ps "[Console]::WriteLine('arch=' + \$env:PROCESSOR_ARCHITECTURE)" 2>/dev/null || true 891 return 0 892 fi 893 if ! vm_running; then die "qemu exited while waiting for ssh (see $QEMU_LOG)"; fi 894 # Capture a screenshot every ~60s so a stalled install is debuggable. 895 if [ $((i % 60)) -eq 0 ]; then 896 shot="$(screenshot "wait-$i" 2>/dev/null || true)" 897 [ -n "$shot" ] && printf ' [%ss] screenshot: %s\n' "$i" "$shot" 898 fi 899 sleep 10 900 i=$((i + 10)) 901 done 902 screenshot "wait-timeout" >/dev/null 2>&1 || true 903 die "ssh did not become ready within ${timeout}s (see $QEMU_LOG and $SHOT_DIR)" 904 } 905 906 ssh_arch() { 907 local arch=aarch64 908 if [ $# -ge 1 ] && case "${1:-}" in x64|x86_64|amd64|aarch64|arm64|aa64) true ;; *) false ;; esac; then 909 arch="$1"; shift 910 fi 911 ssh_setup "$arch" 912 if [ $# -eq 0 ]; then 913 exec ssh "${SSH_ARGS[@]}" "$SSH_DEST" 914 fi 915 exec ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$@" 916 } 917 918 # --------------------------------------------------------------------------- 919 # doctor / status 920 # --------------------------------------------------------------------------- 921 922 doctor() { 923 local iso 924 printf 'host: %s/%s\n' "$(uname -s 2>/dev/null)" "$(uname -m 2>/dev/null)" 925 printf 'repo: %s\n' "$ROOT" 926 printf 'vm dir: %s\n' "$VM_ROOT" 927 printf 'cache: %s\n' "$CACHE_ROOT" 928 printf 'hvf: %s\n' "$(sysctl -n kern.hv_support 2>/dev/null || echo '?')" 929 for tool in "$QEMU_BIN" qemu-img ssh base64 hdiutil python3 pnmtopng curl; do 930 if command -v "$tool" >/dev/null 2>&1; then 931 printf ' OK %s (%s)\n' "$tool" "$(command -v "$tool")" 932 else 933 printf ' MISSING %s\n' "$tool" 934 fi 935 done 936 printf 'firmware:\n' 937 printf ' %-7s %s\n' code "$(firmware_code)$([ -f "$(firmware_code)" ] && echo '' || echo ' (MISSING)')" 938 printf ' %-7s %s\n' vars "$(firmware_vars_template)$([ -f "$(firmware_vars_template)" ] && echo '' || echo ' (MISSING)')" 939 printf 'media:\n' 940 if iso="$(win_iso)"; then printf ' windows %s\n' "$iso"; else printf ' windows (none; set KIT_WINDOWS_ISO or drop Win11*Arm64*.iso in %s)\n' "$CACHE_ROOT"; fi 941 printf ' virtio %s%s\n' "$(virtio_iso)" "$([ -f "$(virtio_iso)" ] && echo '' || echo ' (run fetch-virtio)')" 942 printf ' openssh %s%s\n' "$(openssh_zip)" "$([ -f "$(openssh_zip)" ] && echo '' || echo ' (run fetch-openssh)')" 943 printf 'artifacts:\n' 944 printf ' disk %s%s\n' "$DISK_PATH" "$([ -f "$DISK_PATH" ] && echo '' || echo ' (not created)')" 945 printf ' golden %s%s\n' "$GOLDEN_PATH" "$([ -f "$GOLDEN_PATH" ] && echo ' (installed)' || echo ' (none; run install)')" 946 printf ' seed %s%s\n' "$SEED_PATH" "$([ -f "$SEED_PATH" ] && echo '' || echo ' (not built)')" 947 printf ' ssh key %s%s\n' "$SSH_KEY_DEFAULT" "$([ -f "$SSH_KEY_DEFAULT" ] && echo '' || echo ' (not generated)')" 948 printf 'vm: %s\n' "$(vm_running && echo "running (pid $(cat "$PID_FILE"))" || echo 'stopped')" 949 printf ' x64 dest=%s port=%s\n' "$(vm_dest x64)" "$(vm_port x64)" 950 printf ' aarch64 dest=%s port=%s\n' "$(vm_dest aarch64)" "$(vm_port aarch64)" 951 } 952 953 cmd="${1:-}" 954 case "$cmd" in 955 doctor) doctor ;; 956 fetch-virtio) fetch_virtio ;; 957 fetch-openssh) fetch_openssh ;; 958 seed) build_seed ;; 959 prepare) prepare ;; 960 install) install_vm ;; 961 console-install) console_install ;; 962 console) console_boot ;; 963 firstboot) firstboot_vm ;; 964 boot) boot_vm ;; 965 wait-ssh) wait_ssh "${2:-600}" ;; 966 ssh) shift; ssh_arch "$@" ;; 967 screenshot) screenshot "${2:-shot}" ;; 968 snapshot) cache_golden ;; 969 reset) restore_golden ;; 970 stop) powerdown ;; 971 smoke) [ $# -eq 2 ] || { usage; exit 2; }; smoke_arch "$2" ;; 972 run) [ $# -ge 3 ] || { usage; exit 2; }; arch="$2"; exe="$3"; shift 3; run_exe "$arch" "$exe" "$@" ;; 973 run-batch) [ $# -eq 4 ] || { usage; exit 2; }; run_batch "$2" "$3" "$4" ;; 974 -h|--help|help|"") usage ;; 975 *) usage; exit 2 ;; 976 esac