commit 11895ae0ea7cc7f8fc16664bae5d2fff4aa87c1d
parent 60e7c4ca59b86810a9103da2e217a85aa0bd3134
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Thu, 4 Jun 2026 16:41:00 -0700
windows: add ARM64 VM runner for COFF smoke tests
Diffstat:
5 files changed, 1253 insertions(+), 102 deletions(-)
diff --git a/doc/WINDOWS.md b/doc/WINDOWS.md
@@ -53,42 +53,172 @@ default suite does not download release archives.
## Windows VMs
-Execution uses existing Windows VMs reachable by OpenSSH. Configure one or both:
+Execution uses a Windows 11 ARM64 VM under QEMU + Hypervisor.framework (hvf,
+`-cpu host`) on Apple Silicon. A **single ARM64 VM serves both targets**:
+`aarch64-windows` binaries run natively, and `x86_64-windows` binaries run
+through Windows' in-box x64 emulator. There is no hardware acceleration for
+x86_64 on Apple Silicon, so a dedicated x64 VM would need slow TCG software
+emulation; the ARM64 VM with x64 emulation is used instead. So `run x64` and
+`run aarch64` target the same VM.
+
+Everything is driven by **`scripts/windows_vm.sh`** (modeled on
+`scripts/freebsd_vm.sh`). The unattended-install answer file and guest bootstrap
+live in `scripts/win/` (`autounattend.xml.in`, `bootstrap.ps1.in`).
+
+`scripts/windows_vm.sh` with no arguments (or `help`) prints the full command
+list. `scripts/windows_vm.sh doctor` prints host tools, firmware, media, the
+golden-disk status, and whether a VM is running — run it first if anything looks
+off.
+
+### Layout
+
+- `${XDG_CACHE_HOME:-$HOME/.cache}/kit/windows-vm/` — **durable VM state** that
+ survives `make clean`: the golden disk (`win11-arm64-golden.qcow2`), the working
+ disk (`win11-arm64.qcow2`), UEFI vars (`aarch64-vars.fd`), the seed ISO
+ (`kit-seed.iso`), and the SSH key (`ssh/id_ed25519`) — kept here because the
+ golden disk's `authorized_keys` is baked to that key. The Windows / virtio-win /
+ OpenSSH media are cached one level up under `${XDG_CACHE_HOME:-$HOME/.cache}/kit/`.
+- `build/windows-vm/` — **ephemeral per-run state only**: `run/{qemu.pid,qmp.sock,
+ serial.log}` and `screenshots/`. Safe to delete anytime; never holds anything
+ you can't regenerate.
+
+### One-time install (produces the golden disk)
+
+You supply the Windows 11 ARM64 install ISO (Microsoft ISOs can't be
+redistributed or pinned). Point the script at it or drop it in the cache:
```sh
-export KIT_WINDOWS_VM_X64=kit@127.0.0.1
-export KIT_WINDOWS_VM_X64_PORT=2225
+export KIT_WINDOWS_ISO=/path/to/Win11_<ver>_Arm64.iso
+# or, auto-discovered: cp Win11_<ver>_Arm64.iso "${XDG_CACHE_HOME:-$HOME/.cache}/kit/"
+```
+
+The virtio-win NIC driver and a Win32-OpenSSH ARM64 build are fetched +
+checksum-pinned automatically and installed into the guest **offline**, so setup
+needs no Windows Update. The generated `autounattend.xml` seed bypasses the
+TPM 2.0 / Secure Boot / RAM / CPU checks (no swtpm), installs to an NVMe disk
+(in-box driver — no "load driver" step), creates a local `kit` admin account,
+skips the OOBE Microsoft-account flow, and on first logon installs the NetKVM
+driver + OpenSSH and authorizes the host's generated key.
-export KIT_WINDOWS_VM_AARCH64=kit@127.0.0.1
-export KIT_WINDOWS_VM_AARCH64_PORT=2226
+Pick one of:
+
+```sh
+# Automated, hands-off. Applies the image, finishes first boot + bootstrap,
+# then cleanly shuts down and caches the golden disk.
+scripts/windows_vm.sh install
+
+# Interactive. Opens a QEMU window so you can drive Setup (press a key at the
+# "Press any key to boot from CD" prompt and watch). When Setup hits ~100% and
+# reboots, close the window, then run `firstboot` to finish + cache the golden.
+scripts/windows_vm.sh console-install
+scripts/windows_vm.sh firstboot
```
-Optional shared settings:
+Progress screenshots land in `build/windows-vm/screenshots/` (note: the display
+freezes on a boot frame once Windows fully starts — Windows has no `ramfb`
+driver — so a frozen screen is normal; SSH readiness is the real signal). The
+install takes a while under hvf and reboots several times.
+
+When it finishes, the working disk is banked as the golden disk and you never
+have to reinstall.
+
+### Daily use: start, run, stop
```sh
-export KIT_WINDOWS_VM_SSH_KEY=$HOME/.ssh/id_ed25519
-export KIT_WINDOWS_VM_SSH_OPTS="-o UserKnownHostsFile=/dev/null"
+scripts/windows_vm.sh boot # boot the installed VM headless (fast)
+scripts/windows_vm.sh wait-ssh # wait until SSH answers, print guest info
+scripts/windows_vm.sh run x64 build/probe-x64.exe arg1 arg2
+scripts/windows_vm.sh run aarch64 build/probe-arm64.exe
+scripts/windows_vm.sh ssh [cmd...] # shell / one-off command in the VM
+scripts/windows_vm.sh screenshot [name] # capture the framebuffer (debug)
+scripts/windows_vm.sh stop # clean ACPI power down
```
-Probe a VM:
+`run` copies the executable to a temp dir on the guest via `scp` (Win32-OpenSSH's
+sftp subsystem), runs it through PowerShell, returns the guest exit code, and
+removes the temp dir unless `KIT_WINDOWS_VM_KEEP=1`. `console` opens a windowed
+session on the installed VM for manual inspection.
+
+### Golden disk lifecycle
+
+The long install runs **once**; the result is cached as a compressed golden disk
+in the durable cache. The working disk (alongside it in the cache) is restored
+from it cheaply.
```sh
-scripts/windows_vm.sh doctor
-scripts/windows_vm.sh smoke x64
-scripts/windows_vm.sh smoke aarch64
+scripts/windows_vm.sh snapshot # cache the current (stopped) disk as golden
+scripts/windows_vm.sh reset # restore the working disk from golden (discard changes)
+scripts/windows_vm.sh prepare # restore golden if cached, else stage a fresh install
```
-Run a kit-produced executable:
+`boot` auto-restores from the golden disk if the working disk is missing, so a
+fresh checkout only needs the cached golden to be present.
+
+### Command reference
+
+| Command | What it does |
+| --- | --- |
+| `doctor` | host tools, firmware, media, golden + VM status |
+| `fetch-virtio` / `fetch-openssh` | download + verify the pinned guest helpers |
+| `seed` | rebuild the `autounattend.xml` seed ISO |
+| `prepare` | restore golden if cached, else stage for install |
+| `install` | automated unattended install → cache golden |
+| `console-install` | interactive (windowed) install you drive |
+| `firstboot` | finish a fresh install (no install CD) → cache golden |
+| `console` | open a window on the installed VM |
+| `boot` | boot the installed VM headless (background) |
+| `wait-ssh` | wait for SSH, then print guest info |
+| `ssh [cmd]` | ssh into the running VM |
+| `run <arch> exe [args]` | upload + run an exe, return its exit code |
+| `smoke <arch>` | run a small probe in the VM |
+| `screenshot [name]` | capture the framebuffer |
+| `snapshot` / `reset` | cache / restore the golden disk |
+| `stop` | clean ACPI power down |
+
+### Test integration
```sh
-scripts/windows_vm.sh run x64 build/probe-x64.exe arg1 arg2
-scripts/windows_vm.sh run aarch64 build/probe-arm64.exe
+make test-coff-windows-vm
+```
+
+boots the VM, waits for SSH, and runs the hosted PE/COFF smokes against it so
+their per-program run lanes execute for real. `make test-coff-windows-ucrt`
+stays link-only (build + `objdump -p`, no VM) and `make test-coff` stays
+self-skipping when no UCRT sysroot is present.
+
+### How it works (and a key quirk)
+
+Windows Setup's NVRAM "Windows Boot Manager" entry does **not** persist in QEMU's
+emulated firmware, so after Setup applies the image and reboots, a still-attached
+bootable install CD makes the firmware loop back into Setup instead of booting
+the new OS. The fix (handled by `install`/`firstboot`): reset the UEFI vars
+(forcing a fresh enumeration that finds Windows' ARM fallback loader
+`\EFI\Boot\bootaa64.efi`) and boot **without** the install CD. QEMU is also
+launched under the `argv[0]` `kit-qemu-win` so a broad `pkill -f
+qemu-system-aarch64` (e.g. from a concurrent FreeBSD VM workflow in the same
+repo) cannot kill it.
+
+### Using an external Windows VM instead
+
+To run against an existing Windows VM reachable over SSH (a remote arm64 box, or
+a real x64 machine) instead of the local hvf VM, set the endpoint env and the
+smoke tests / `run` will use it in preference to the local VM:
+
+```sh
+export KIT_WINDOWS_VM_X64=user@host KIT_WINDOWS_VM_X64_PORT=22
+export KIT_WINDOWS_VM_AARCH64=user@host KIT_WINDOWS_VM_AARCH64_PORT=22
+export KIT_WINDOWS_VM_SSH_KEY=$HOME/.ssh/id_ed25519
+export KIT_WINDOWS_VM_SSH_OPTS="-o UserKnownHostsFile=/dev/null"
```
-The runner uploads the executable over SSH stdin into `%TEMP%`, runs it through
-PowerShell, returns the guest exit code, and removes the temporary directory
-unless `KIT_WINDOWS_VM_KEEP=1` is set.
+The COFF Windows smoke scripts prefer a configured VM endpoint. If none is set
+and no local VM is provisioned, they fall back to the podman/Wine path and
+self-skip when that is unavailable.
+
+### Tuning knobs (env)
-The COFF Windows smoke scripts prefer configured VMs. If no VM endpoint is set,
-they fall back to the existing podman/Wine path and self-skip when that is not
-available.
+`KIT_WINDOWS_VM_PORT` (host SSH port, default 2227), `KIT_WINDOWS_MEM` /
+`KIT_WINDOWS_CPUS` (guest RAM MiB / vcpus), `KIT_WINDOWS_VM_DISPLAY` (`none` or
+`cocoa`), `KIT_WINDOWS_EDITION` (default `Windows 11 Pro`),
+`KIT_WINDOWS_SSH_USER` / `KIT_WINDOWS_SSH_PASS` (default `kit`/`kit`),
+`KIT_WINDOWS_GOLDEN` (golden-disk path).
diff --git a/mk/test.mk b/mk/test.mk
@@ -686,6 +686,28 @@ test-coff-windows-ucrt: bin rt-x86_64-pc-windows rt-aarch64-windows windows-ucrt
KIT_SYSROOT=$(abspath build/llvm-mingw/20260602/ucrt) bash test/coff/windows-ucrt-hosted-smoke.sh
KIT_SYSROOT=$(abspath build/llvm-mingw/20260602/ucrt) bash test/coff/windows-system-dlls-smoke.sh
+# Opt-in: run the COFF/PE hosted smokes against a real Windows 11 ARM64 VM, so
+# their per-program run lanes execute for real instead of self-skipping. On
+# Apple Silicon a single hvf-accelerated VM serves both targets: aarch64-windows
+# runs natively and x86_64-windows runs via the in-box x64 emulator, so both run
+# lanes point at the same endpoint. Requires a provisioned VM and a user-supplied
+# Windows ISO (see doc/WINDOWS.md), then boots it, waits for SSH, and runs the
+# smokes against it.
+KIT_WINDOWS_VM_USER ?= kit
+KIT_WINDOWS_VM_PORT ?= 2227
+# SSH key is left unset so windows_vm.sh uses its default in the durable cache
+# (~/.cache/kit/windows-vm/ssh/id_ed25519); the working disk auto-restores from
+# the cached golden, so a clean build/ still finds a ready VM.
+_WIN_VM_ENV = KIT_SYSROOT=$(abspath build/llvm-mingw/20260602/ucrt) \
+ KIT_WINDOWS_VM_AARCH64=$(KIT_WINDOWS_VM_USER)@127.0.0.1 KIT_WINDOWS_VM_AARCH64_PORT=$(KIT_WINDOWS_VM_PORT) \
+ KIT_WINDOWS_VM_X64=$(KIT_WINDOWS_VM_USER)@127.0.0.1 KIT_WINDOWS_VM_X64_PORT=$(KIT_WINDOWS_VM_PORT)
+
+test-coff-windows-vm: bin rt-x86_64-pc-windows rt-aarch64-windows windows-ucrt-sysroots
+ bash scripts/windows_vm.sh boot
+ bash scripts/windows_vm.sh wait-ssh 600
+ $(_WIN_VM_ENV) bash test/coff/windows-ucrt-hosted-smoke.sh
+ $(_WIN_VM_ENV) bash test/coff/windows-system-dlls-smoke.sh
+
# The parse/asm/macho harnesses select a cross-target via KIT_TEST_ARCH
# (default aa64); the link rt dependency is resolved through the shared
# _TEST_RT_<arch> map defined above (near test-rt-runtime).
diff --git a/scripts/win/autounattend.xml.in b/scripts/win/autounattend.xml.in
@@ -0,0 +1,214 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ kit unattended install answer file for Windows 11 ARM64 under QEMU.
+ Generated into a seed ISO by scripts/windows_vm.sh; the AT-sign placeholders
+ are substituted at seed-build time. Targets a single ARM64 VM that runs aarch64-windows
+ binaries natively and x86_64-windows binaries via the in-box x64 emulator.
+
+ Key choices:
+ - System disk is NVMe (in-box stornvme driver), so Setup sees the disk with
+ no "load driver" step.
+ - TPM 2.0 / Secure Boot / RAM / CPU checks are bypassed via LabConfig so the
+ VM needs no swtpm and no Secure Boot.
+ - A local admin account + AutoLogon skips the OOBE Microsoft-account screen,
+ so first boot needs no network.
+ - FirstLogonCommands hands off to \kit\bootstrap.ps1 on the seed media, which
+ installs the virtio NetKVM driver and OpenSSH server (both bundled, offline).
+-->
+<unattend xmlns="urn:schemas-microsoft-com:unattend">
+ <settings pass="windowsPE">
+ <component name="Microsoft-Windows-International-Core-WinPE"
+ processorArchitecture="arm64"
+ publicKeyToken="31bf3856ad364e35" language="neutral"
+ versionScope="nonSxS"
+ xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
+ <SetupUILanguage>
+ <UILanguage>en-US</UILanguage>
+ </SetupUILanguage>
+ <InputLocale>0409:00000409</InputLocale>
+ <SystemLocale>en-US</SystemLocale>
+ <UILanguage>en-US</UILanguage>
+ <UserLocale>en-US</UserLocale>
+ </component>
+ <component name="Microsoft-Windows-Setup"
+ processorArchitecture="arm64"
+ publicKeyToken="31bf3856ad364e35" language="neutral"
+ versionScope="nonSxS"
+ xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
+ <!-- Bypass the Windows 11 hardware gate. These run in WinPE before the
+ compatibility check, so no TPM / Secure Boot device is required. -->
+ <RunSynchronous>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>1</Order>
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassTPMCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>2</Order>
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassSecureBootCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>3</Order>
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassRAMCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>4</Order>
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassStorageCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>5</Order>
+ <Path>reg add HKLM\System\Setup\LabConfig /v BypassCPUCheck /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ </RunSynchronous>
+ <DiskConfiguration>
+ <Disk wcm:action="add">
+ <DiskID>0</DiskID>
+ <WillWipeDisk>true</WillWipeDisk>
+ <CreatePartitions>
+ <CreatePartition wcm:action="add">
+ <Order>1</Order>
+ <Type>EFI</Type>
+ <Size>260</Size>
+ </CreatePartition>
+ <CreatePartition wcm:action="add">
+ <Order>2</Order>
+ <Type>MSR</Type>
+ <Size>128</Size>
+ </CreatePartition>
+ <CreatePartition wcm:action="add">
+ <Order>3</Order>
+ <Type>Primary</Type>
+ <Extend>true</Extend>
+ </CreatePartition>
+ </CreatePartitions>
+ <ModifyPartitions>
+ <ModifyPartition wcm:action="add">
+ <Order>1</Order>
+ <PartitionID>1</PartitionID>
+ <Format>FAT32</Format>
+ <Label>System</Label>
+ </ModifyPartition>
+ <ModifyPartition wcm:action="add">
+ <Order>2</Order>
+ <PartitionID>2</PartitionID>
+ </ModifyPartition>
+ <ModifyPartition wcm:action="add">
+ <Order>3</Order>
+ <PartitionID>3</PartitionID>
+ <Format>NTFS</Format>
+ <Label>Windows</Label>
+ <Letter>C</Letter>
+ </ModifyPartition>
+ </ModifyPartitions>
+ </Disk>
+ <WillShowUI>OnError</WillShowUI>
+ </DiskConfiguration>
+ <ImageInstall>
+ <OSImage>
+ <InstallTo>
+ <DiskID>0</DiskID>
+ <PartitionID>3</PartitionID>
+ </InstallTo>
+ <InstallFrom>
+ <MetaData wcm:action="add">
+ <Key>/IMAGE/NAME</Key>
+ <Value>@WIN_EDITION@</Value>
+ </MetaData>
+ </InstallFrom>
+ <WillShowUI>OnError</WillShowUI>
+ </OSImage>
+ </ImageInstall>
+ <UserData>
+ <ProductKey>
+ <Key>@PRODUCT_KEY@</Key>
+ <WillShowUI>OnError</WillShowUI>
+ </ProductKey>
+ <AcceptEula>true</AcceptEula>
+ <FullName>kit</FullName>
+ <Organization>kit</Organization>
+ </UserData>
+ </component>
+ </settings>
+
+ <settings pass="specialize">
+ <component name="Microsoft-Windows-Shell-Setup"
+ processorArchitecture="arm64"
+ publicKeyToken="31bf3856ad364e35" language="neutral"
+ versionScope="nonSxS"
+ xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
+ <ComputerName>KITWIN</ComputerName>
+ <TimeZone>UTC</TimeZone>
+ </component>
+ <component name="Microsoft-Windows-Deployment"
+ processorArchitecture="arm64"
+ publicKeyToken="31bf3856ad364e35" language="neutral"
+ versionScope="nonSxS"
+ xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
+ <!-- Belt-and-suspenders: also disable the OOBE "network required" gate. -->
+ <RunSynchronous>
+ <RunSynchronousCommand wcm:action="add">
+ <Order>1</Order>
+ <Path>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE /v BypassNRO /t REG_DWORD /d 1 /f</Path>
+ </RunSynchronousCommand>
+ </RunSynchronous>
+ </component>
+ </settings>
+
+ <settings pass="oobeSystem">
+ <component name="Microsoft-Windows-International-Core"
+ processorArchitecture="arm64"
+ publicKeyToken="31bf3856ad364e35" language="neutral"
+ versionScope="nonSxS"
+ xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
+ <InputLocale>0409:00000409</InputLocale>
+ <SystemLocale>en-US</SystemLocale>
+ <UILanguage>en-US</UILanguage>
+ <UserLocale>en-US</UserLocale>
+ </component>
+ <component name="Microsoft-Windows-Shell-Setup"
+ processorArchitecture="arm64"
+ publicKeyToken="31bf3856ad364e35" language="neutral"
+ versionScope="nonSxS"
+ xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
+ <OOBE>
+ <HideEULAPage>true</HideEULAPage>
+ <HideLocalAccountScreen>true</HideLocalAccountScreen>
+ <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
+ <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
+ <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
+ <ProtectYourPC>3</ProtectYourPC>
+ <SkipMachineOOBE>true</SkipMachineOOBE>
+ <SkipUserOOBE>true</SkipUserOOBE>
+ </OOBE>
+ <UserAccounts>
+ <LocalAccounts>
+ <LocalAccount wcm:action="add">
+ <Name>@KIT_USER@</Name>
+ <DisplayName>@KIT_USER@</DisplayName>
+ <Group>Administrators</Group>
+ <Password>
+ <Value>@KIT_PASS@</Value>
+ <PlainText>true</PlainText>
+ </Password>
+ </LocalAccount>
+ </LocalAccounts>
+ </UserAccounts>
+ <AutoLogon>
+ <Enabled>true</Enabled>
+ <Username>@KIT_USER@</Username>
+ <LogonCount>5</LogonCount>
+ <Password>
+ <Value>@KIT_PASS@</Value>
+ <PlainText>true</PlainText>
+ </Password>
+ </AutoLogon>
+ <FirstLogonCommands>
+ <SynchronousCommand wcm:action="add">
+ <Order>1</Order>
+ <CommandLine>cmd /c "for %d in (D E F G H I J K L M N O) do @if exist %d:\kit\bootstrap.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File %d:\kit\bootstrap.ps1"</CommandLine>
+ <Description>kit guest bootstrap (virtio net driver + OpenSSH)</Description>
+ <RequiresUserInput>false</RequiresUserInput>
+ </SynchronousCommand>
+ </FirstLogonCommands>
+ </component>
+ </settings>
+</unattend>
diff --git a/scripts/win/bootstrap.ps1.in b/scripts/win/bootstrap.ps1.in
@@ -0,0 +1,80 @@
+# kit guest bootstrap, run once by FirstLogonCommands from the seed media.
+# Brings up networking and an SSH server with no Windows Update dependency:
+# 1. Install the virtio NetKVM network driver from the attached virtio-win CD.
+# 2. Install OpenSSH server from the OpenSSH-ARM64.zip bundled on the seed CD.
+# 3. Authorize the host's SSH key and open the firewall.
+# The AT-sign placeholders below are substituted at seed-build time.
+$ErrorActionPreference = 'Continue'
+Start-Transcript -Path C:\kit-bootstrap.log -Append | Out-Null
+
+function Find-OnAnyDrive([string]$rel) {
+ foreach ($v in (Get-PSDrive -PSProvider FileSystem -ErrorAction SilentlyContinue)) {
+ $p = Join-Path $v.Root $rel
+ if (Test-Path -LiteralPath $p) { return (Get-Item -LiteralPath $p).FullName }
+ }
+ return $null
+}
+
+try {
+ # --- 1. virtio network driver (NetKVM) ---------------------------------
+ $netinf = Find-OnAnyDrive 'NetKVM\w11\ARM64\netkvm.inf'
+ if ($netinf) {
+ Write-Output "kit: installing NetKVM driver from $netinf"
+ & pnputil.exe /add-driver $netinf /install
+ } else {
+ Write-Output 'kit: WARNING NetKVM driver not found on any drive'
+ }
+
+ # --- 2. OpenSSH server (offline, from the seed CD) ---------------------
+ $zip = Join-Path $PSScriptRoot 'OpenSSH-ARM64.zip'
+ $dest = 'C:\Program Files\OpenSSH'
+ if (Test-Path -LiteralPath $zip) {
+ Write-Output "kit: installing OpenSSH from $zip"
+ if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
+ Expand-Archive -LiteralPath $zip -DestinationPath 'C:\Program Files' -Force
+ if (Test-Path 'C:\Program Files\OpenSSH-ARM64') {
+ Rename-Item 'C:\Program Files\OpenSSH-ARM64' $dest
+ }
+ & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $dest 'install-sshd.ps1')
+ } else {
+ Write-Output 'kit: OpenSSH zip not found; trying Windows capability'
+ Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 -ErrorAction SilentlyContinue
+ }
+
+ Set-Service -Name sshd -StartupType Automatic
+ New-NetFirewallRule -Name 'kit-sshd' -DisplayName 'kit OpenSSH Server' `
+ -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 `
+ -ErrorAction SilentlyContinue | Out-Null
+
+ # --- 3. Authorize the host key ----------------------------------------
+ $pub = @'
+@SSH_PUBKEY@
+'@
+ $pub = $pub.Trim()
+ $sshProgramData = 'C:\ProgramData\ssh'
+ New-Item -ItemType Directory -Force -Path $sshProgramData | Out-Null
+ # sshd treats members of the Administrators group specially: it reads
+ # administrators_authorized_keys (not the per-user file) and requires the
+ # file be writable only by Administrators/SYSTEM.
+ $ak = Join-Path $sshProgramData 'administrators_authorized_keys'
+ # Write with a trailing newline (no -NoNewline) so a later append can't
+ # concatenate onto this key's line.
+ Set-Content -LiteralPath $ak -Value $pub -Encoding ascii
+ icacls $ak /inheritance:r /grant 'Administrators:F' /grant 'SYSTEM:F' | Out-Null
+
+ $userSsh = 'C:\Users\@KIT_USER@\.ssh'
+ New-Item -ItemType Directory -Force -Path $userSsh | Out-Null
+ Set-Content -LiteralPath (Join-Path $userSsh 'authorized_keys') -Value $pub -Encoding ascii
+
+ Start-Service sshd -ErrorAction SilentlyContinue
+ Restart-Service sshd -ErrorAction SilentlyContinue
+
+ Set-Content -LiteralPath C:\kit-ready.txt `
+ -Value ('kit-ready ' + (Get-Date -Format o)) -Encoding ascii
+ Write-Output 'kit: bootstrap complete'
+} catch {
+ Write-Output ('kit: bootstrap ERROR ' + ($_ | Out-String))
+ $_ | Out-String | Set-Content -LiteralPath C:\kit-bootstrap-error.log
+} finally {
+ Stop-Transcript | Out-Null
+}
diff --git a/scripts/windows_vm.sh b/scripts/windows_vm.sh
@@ -1,35 +1,109 @@
#!/usr/bin/env bash
-# Run kit-produced Windows executables inside configured Windows VMs over SSH.
+# Provision and drive a Windows 11 ARM64 VM under QEMU+hvf on Apple Silicon, and
+# run kit-produced Windows executables inside it over SSH.
+#
+# On Apple Silicon there is no hardware acceleration for x86_64, so a single
+# hvf-accelerated Windows 11 ARM64 VM serves both Windows targets: aarch64-windows
+# binaries run natively and x86_64-windows binaries run via the in-box x64
+# emulator (Prism). The `run x64` and `run aarch64` paths therefore target the
+# same VM.
+#
+# The Windows install media is supplied by the user (see KIT_WINDOWS_ISO). The
+# virtio-win network driver and OpenSSH server are fetched/bundled and installed
+# unattended, so first boot needs no Windows Update and no interactive setup.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+TEMPLATE_DIR="$ROOT/scripts/win"
+VM_ROOT="${KIT_WINDOWS_VM_DIR:-$ROOT/build/windows-vm}"
+XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
+CACHE_ROOT="${KIT_WINDOWS_CACHE_DIR:-$XDG_CACHE_HOME/kit}"
+QEMU_SHARE="${KIT_QEMU_SHARE:-/opt/homebrew/share/qemu}"
+QEMU_BIN="${KIT_WINDOWS_QEMU:-qemu-system-aarch64}"
+
+# Guest / VM settings.
+SSH_USER="${KIT_WINDOWS_SSH_USER:-kit}"
+SSH_PASS="${KIT_WINDOWS_SSH_PASS:-kit}"
+VM_PORT="${KIT_WINDOWS_VM_PORT:-2227}"
+VM_MEM="${KIT_WINDOWS_MEM:-6144}"
+VM_CPUS="${KIT_WINDOWS_CPUS:-4}"
+VM_DISK_SIZE="${KIT_WINDOWS_DISK_SIZE:-64G}"
+# QEMU display backend. Headless (none) for automated install/boot + screendump;
+# the console commands switch this to a visible window (cocoa on macOS) so the
+# install can be driven interactively.
+DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-none}"
+WIN_EDITION="${KIT_WINDOWS_EDITION:-Windows 11 Pro}"
+# Generic Windows 11 Pro install key (no activation; lets Setup skip the key UI).
+PRODUCT_KEY="${KIT_WINDOWS_PRODUCT_KEY:-VK7JG-NPHTM-C97JM-9MPGT-3V66T}"
+
+# Pinned guest helpers.
+VIRTIO_VERSION="${KIT_VIRTIO_WIN_VERSION:-0.1.266}"
+VIRTIO_SHA256="${KIT_VIRTIO_WIN_SHA256:-57b0f6dc8dc92dc2ae8621f8b1bfbd8a873de9bedc788c4c4b305ea28acc77cd}"
+OPENSSH_VERSION="${KIT_OPENSSH_VERSION:-10.0.0.0p2-Preview}"
+OPENSSH_SHA256="${KIT_OPENSSH_SHA256:-698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42}"
+
POWERSHELL="${KIT_WINDOWS_VM_POWERSHELL:-powershell.exe}"
+# Durable VM state lives in the cache dir so `make clean` (which wipes build/)
+# cannot destroy it: the disks, UEFI vars, seed ISO, and — critically — the SSH
+# key the golden disk's authorized_keys is baked to. Only ephemeral per-run state
+# (pidfile, QMP socket, logs, screenshots) lives under build/.
+CACHE_VM="${KIT_WINDOWS_VM_CACHE:-$CACHE_ROOT/windows-vm}"
+DISK_PATH="$CACHE_VM/win11-arm64.qcow2"
+# Durable "golden" disk: a fully-installed, bootstrapped, cleanly-shut-down image
+# so the long install runs once; the working DISK_PATH is restored from it cheaply.
+GOLDEN_PATH="${KIT_WINDOWS_GOLDEN:-$CACHE_VM/win11-arm64-golden.qcow2}"
+PROVISIONED_MARKER="$CACHE_VM/win11-arm64.provisioned"
+VARS_PATH="$CACHE_VM/aarch64-vars.fd"
+SEED_PATH="$CACHE_VM/kit-seed.iso"
+SSH_KEY_DEFAULT="$CACHE_VM/ssh/id_ed25519"
+QMP_SOCK="$VM_ROOT/run/qmp.sock"
+PID_FILE="$VM_ROOT/run/qemu.pid"
+QEMU_LOG="$VM_ROOT/run/qemu.log"
+SHOT_DIR="$VM_ROOT/screenshots"
+
usage() {
cat <<EOF
usage: scripts/windows_vm.sh <command> [args...]
-commands:
- doctor print local tools and configured VM endpoints
- smoke <arch> run a small PowerShell probe in the VM
+provisioning (single Windows 11 ARM64 VM, serves both arches):
+ doctor print tool / firmware / media / VM status
+ fetch-virtio download + verify the pinned virtio-win driver ISO
+ fetch-openssh download + verify the pinned Win32-OpenSSH ARM64 zip
+ seed (re)build the unattended seed ISO (autounattend + bootstrap)
+ prepare restore golden disk if cached, else stage for install
+ install unattended install once, then cache the golden disk
+ console-install interactive install in a QEMU window (you drive Setup);
+ keeps the seed unless KIT_WINDOWS_NO_SEED=1
+ console open a window on the installed VM (manual inspection)
+ firstboot finish a fresh install: boot it (no install CD) so the
+ first-logon bootstrap runs, then cache the golden disk
+ boot boot the installed VM headless in the background
+ wait-ssh wait until the VM answers SSH, then print guest info
+ ssh [cmd...] ssh into the running VM
+ screenshot [name] capture the VM framebuffer to build/windows-vm/screenshots
+ snapshot cache the current (stopped) disk as the golden disk
+ reset restore the working disk from the golden disk
+ stop power down the running VM
+
+execution (used by the COFF/PE smoke tests):
+ smoke <arch> run a small probe in the VM
run <arch> exe [args] upload exe to the VM, run it, then remove it
-arches:
- x64 | x86_64 | amd64 | aarch64 | arm64 | aa64
+arches: x64 | x86_64 | amd64 | aarch64 | arm64 | aa64
env:
- KIT_WINDOWS_VM_X64 SSH destination for the x64 Windows VM
- KIT_WINDOWS_VM_AARCH64 SSH destination for the arm64 Windows VM
- KIT_WINDOWS_VM_X64_PORT / KIT_WINDOWS_VM_AARCH64_PORT
- optional SSH ports
- KIT_WINDOWS_VM_SSH_KEY optional private key
- KIT_WINDOWS_VM_SSH_OPTS optional extra ssh options
+ KIT_WINDOWS_ISO path to the Windows 11 ARM64 install ISO (required to
+ install; else auto-discovered in $CACHE_ROOT)
+ KIT_WINDOWS_VM_PORT host SSH port forwarded to the VM (default $VM_PORT)
+ KIT_WINDOWS_MEM/CPUS guest memory MiB / vcpus (default $VM_MEM / $VM_CPUS)
+ KIT_WINDOWS_VM_X64 SSH destination override for the x64 lane
+ KIT_WINDOWS_VM_AARCH64 SSH destination override for the arm64 lane
+ KIT_WINDOWS_VM_*_PORT optional SSH port overrides
+ KIT_WINDOWS_VM_SSH_KEY SSH private key (default $SSH_KEY_DEFAULT)
+ KIT_WINDOWS_VM_SSH_OPTS extra ssh options
KIT_WINDOWS_VM_KEEP keep uploaded temp dirs when set non-empty
-
-Example:
- KIT_WINDOWS_VM_X64=kit@127.0.0.1 KIT_WINDOWS_VM_X64_PORT=2225 \\
- scripts/windows_vm.sh run x64 build/probe.exe
EOF
}
@@ -46,82 +120,643 @@ canon_arch() {
esac
}
-env_get() {
- local name=$1
- printf '%s\n' "${!name:-}"
+# ---------------------------------------------------------------------------
+# media discovery + fetch
+# ---------------------------------------------------------------------------
+
+sha256_file() {
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "$1" | awk '{ print $1 }'
+ elif command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | awk '{ print $1 }'
+ else
+ die "missing shasum or sha256sum"
+ fi
+}
+
+verify_sha256() {
+ local path=$1 expect=$2 got
+ [ -n "$expect" ] || return 0
+ got="$(sha256_file "$path")"
+ [ "$got" = "$expect" ] ||
+ die "checksum mismatch for $(basename "$path"): got $got want $expect"
+}
+
+win_iso() {
+ if [ -n "${KIT_WINDOWS_ISO:-}" ]; then
+ printf '%s\n' "$KIT_WINDOWS_ISO"
+ return 0
+ fi
+ local d
+ for d in "$CACHE_ROOT"/Win11*[Aa]rm64*.iso "$CACHE_ROOT"/*[Aa]rm64*.iso; do
+ [ -f "$d" ] && { printf '%s\n' "$d"; return 0; }
+ done
+ return 1
+}
+
+virtio_iso() {
+ if [ -n "${KIT_VIRTIO_WIN_ISO:-}" ]; then
+ printf '%s\n' "$KIT_VIRTIO_WIN_ISO"
+ else
+ printf '%s\n' "$CACHE_ROOT/virtio-win/virtio-win-$VIRTIO_VERSION.iso"
+ fi
+}
+
+openssh_zip() {
+ if [ -n "${KIT_OPENSSH_ZIP:-}" ]; then
+ printf '%s\n' "$KIT_OPENSSH_ZIP"
+ else
+ printf '%s\n' "$CACHE_ROOT/openssh/OpenSSH-ARM64-$OPENSSH_VERSION.zip"
+ fi
+}
+
+fetch_virtio() {
+ local out url
+ out="$(virtio_iso)"
+ if [ -f "$out" ]; then
+ verify_sha256 "$out" "$VIRTIO_SHA256"
+ printf 'virtio-win present: %s\n' "$out"
+ return 0
+ fi
+ command -v curl >/dev/null 2>&1 || die "curl not found"
+ url="https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-$VIRTIO_VERSION-1/virtio-win-$VIRTIO_VERSION.iso"
+ mkdir -p "$(dirname "$out")"
+ printf 'download: %s\n' "$url"
+ curl -fL --retry 3 --retry-delay 2 -o "$out.part" "$url"
+ mv "$out.part" "$out"
+ verify_sha256 "$out" "$VIRTIO_SHA256"
+ printf 'verified: %s\n' "$out"
+}
+
+fetch_openssh() {
+ local out url
+ out="$(openssh_zip)"
+ if [ -f "$out" ]; then
+ verify_sha256 "$out" "$OPENSSH_SHA256"
+ printf 'openssh present: %s\n' "$out"
+ return 0
+ fi
+ command -v curl >/dev/null 2>&1 || die "curl not found"
+ url="https://github.com/PowerShell/Win32-OpenSSH/releases/download/$OPENSSH_VERSION/OpenSSH-ARM64.zip"
+ mkdir -p "$(dirname "$out")"
+ printf 'download: %s\n' "$url"
+ curl -fL --retry 3 --retry-delay 2 -o "$out.part" "$url"
+ mv "$out.part" "$out"
+ verify_sha256 "$out" "$OPENSSH_SHA256"
+ printf 'verified: %s\n' "$out"
+}
+
+# ---------------------------------------------------------------------------
+# provisioning artifacts
+# ---------------------------------------------------------------------------
+
+ensure_ssh_key() {
+ local key="${KIT_WINDOWS_VM_SSH_KEY:-$SSH_KEY_DEFAULT}"
+ if [ ! -f "$key" ]; then
+ mkdir -p "$(dirname "$key")"
+ ssh-keygen -q -t ed25519 -N "" -C "kit-windows-vm" -f "$key"
+ fi
+ printf '%s\n' "$key"
+}
+
+create_disk() {
+ command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found"
+ mkdir -p "$(dirname "$DISK_PATH")"
+ if [ -f "$DISK_PATH" ]; then
+ printf 'disk already present: %s\n' "$DISK_PATH"
+ else
+ qemu-img create -f qcow2 "$DISK_PATH" "$VM_DISK_SIZE" >/dev/null
+ printf 'disk created: %s (%s)\n' "$DISK_PATH" "$VM_DISK_SIZE"
+ fi
+}
+
+# Save the cleanly-shut-down working disk to the durable golden cache.
+cache_golden() {
+ vm_running && die "stop the VM before caching the golden disk"
+ [ -f "$DISK_PATH" ] || die "no working disk to cache: $DISK_PATH"
+ command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found"
+ mkdir -p "$(dirname "$GOLDEN_PATH")"
+ printf 'cache golden disk: %s -> %s\n' "$DISK_PATH" "$GOLDEN_PATH"
+ qemu-img convert -O qcow2 -c "$DISK_PATH" "$GOLDEN_PATH.tmp"
+ mv "$GOLDEN_PATH.tmp" "$GOLDEN_PATH"
+ touch "$PROVISIONED_MARKER"
+ printf 'golden cached. boot with: scripts/windows_vm.sh boot\n'
+}
+
+# Restore the working disk from the golden cache (cheap; no reinstall).
+restore_golden() {
+ [ -f "$GOLDEN_PATH" ] || die "no golden disk cached: $GOLDEN_PATH (run install first)"
+ vm_running && die "stop the VM before restoring the golden disk"
+ command -v qemu-img >/dev/null 2>&1 || die "qemu-img not found"
+ mkdir -p "$(dirname "$DISK_PATH")"
+ printf 'restore golden disk: %s -> %s\n' "$GOLDEN_PATH" "$DISK_PATH"
+ qemu-img convert -O qcow2 "$GOLDEN_PATH" "$DISK_PATH.tmp"
+ mv "$DISK_PATH.tmp" "$DISK_PATH"
+ touch "$PROVISIONED_MARKER"
+}
+
+firmware_code() { printf '%s\n' "$QEMU_SHARE/edk2-aarch64-code.fd"; }
+firmware_vars_template() { printf '%s\n' "$QEMU_SHARE/edk2-arm-vars.fd"; }
+
+ensure_firmware_vars() {
+ local code tmpl
+ code="$(firmware_code)"
+ tmpl="$(firmware_vars_template)"
+ [ -f "$code" ] || die "missing QEMU firmware code: $code"
+ [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl"
+ mkdir -p "$(dirname "$VARS_PATH")"
+ # Keep the vars file across boots: it holds the "Windows Boot Manager" NVRAM
+ # entry created during install. Only seed it from the pristine template when
+ # it does not exist yet.
+ if [ ! -f "$VARS_PATH" ]; then
+ cp "$tmpl" "$VARS_PATH"
+ fi
+}
+
+# Reset UEFI NVRAM to the pristine template. Used at the start of an install so a
+# prior attempt's stale boot entries (e.g. a "Windows Boot Manager" pointing at a
+# now-wiped GPT partition) cannot derail the boot order; the install itself
+# writes the correct entry, which then persists for later boots.
+reset_firmware_vars() {
+ local code tmpl
+ code="$(firmware_code)"
+ tmpl="$(firmware_vars_template)"
+ [ -f "$code" ] || die "missing QEMU firmware code: $code"
+ [ -f "$tmpl" ] || die "missing QEMU firmware vars template: $tmpl"
+ mkdir -p "$(dirname "$VARS_PATH")"
+ cp "$tmpl" "$VARS_PATH"
}
+build_seed() {
+ local key pub seeddir
+ command -v hdiutil >/dev/null 2>&1 || die "hdiutil not found (macOS host required)"
+ key="$(ensure_ssh_key)"
+ pub="$(cat "$key.pub")"
+ fetch_virtio >/dev/null
+ fetch_openssh >/dev/null
+ [ -r "$TEMPLATE_DIR/autounattend.xml.in" ] || die "missing $TEMPLATE_DIR/autounattend.xml.in"
+ [ -r "$TEMPLATE_DIR/bootstrap.ps1.in" ] || die "missing $TEMPLATE_DIR/bootstrap.ps1.in"
+
+ mkdir -p "$CACHE_VM"
+ seeddir="$CACHE_VM/seed-contents"
+ rm -rf "$seeddir"
+ mkdir -p "$seeddir/kit"
+
+ # autounattend.xml lives at the media root so Windows Setup auto-detects it.
+ sed -e "s|@WIN_EDITION@|$WIN_EDITION|g" \
+ -e "s|@PRODUCT_KEY@|$PRODUCT_KEY|g" \
+ -e "s|@KIT_USER@|$SSH_USER|g" \
+ -e "s|@KIT_PASS@|$SSH_PASS|g" \
+ "$TEMPLATE_DIR/autounattend.xml.in" > "$seeddir/autounattend.xml"
+
+ # bootstrap.ps1 + the OpenSSH bundle live under \kit on the seed media.
+ # Substitute the public key via a temp file to keep slashes/+ literal.
+ awk -v pub="$pub" -v user="$SSH_USER" '
+ { gsub(/@SSH_PUBKEY@/, pub); gsub(/@KIT_USER@/, user); print }
+ ' "$TEMPLATE_DIR/bootstrap.ps1.in" > "$seeddir/kit/bootstrap.ps1"
+ cp "$(openssh_zip)" "$seeddir/kit/OpenSSH-ARM64.zip"
+
+ rm -f "$SEED_PATH"
+ hdiutil makehybrid -quiet -iso -joliet -default-volume-name KITSEED \
+ -o "$SEED_PATH" "$seeddir"
+ printf 'seed ready: %s\n' "$SEED_PATH"
+}
+
+prepare() {
+ ensure_firmware_vars
+ ensure_ssh_key >/dev/null
+ if [ -f "$GOLDEN_PATH" ]; then
+ restore_golden
+ printf 'prepared from golden disk. boot with: scripts/windows_vm.sh boot\n'
+ else
+ create_disk
+ build_seed
+ printf 'prepared for install. install with: scripts/windows_vm.sh install\n'
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# QMP control (python handles the capabilities handshake)
+# ---------------------------------------------------------------------------
+
+qmp() {
+ # qmp <json-command> [<json-command> ...] : run commands over the QMP socket.
+ [ -S "$QMP_SOCK" ] || { printf 'windows-vm: QMP socket not present (%s)\n' "$QMP_SOCK" >&2; return 1; }
+ python3 - "$QMP_SOCK" "$@" <<'PY'
+import socket, sys, json
+sock = sys.argv[1]
+cmds = sys.argv[2:]
+s = socket.socket(socket.AF_UNIX)
+s.settimeout(10)
+s.connect(sock)
+f = s.makefile('rwb', buffering=0)
+f.readline() # greeting
+f.write(b'{"execute":"qmp_capabilities"}\n'); f.readline()
+for c in cmds:
+ f.write((c + "\n").encode())
+ print(f.readline().decode().strip())
+PY
+}
+
+screenshot() {
+ local name="${1:-shot}" base ppm png
+ mkdir -p "$SHOT_DIR"
+ base="$SHOT_DIR/$name"
+ png="$base.png"
+ ppm="$base.ppm"
+ rm -f "$png" "$ppm"
+ # QEMU 6+ supports PNG output directly; fall back to PPM + pnmtopng.
+ if qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$png\",\"format\":\"png\"}}" 2>/dev/null \
+ | grep -q '"return"' && [ -s "$png" ]; then
+ printf '%s\n' "$png"
+ return 0
+ fi
+ qmp "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"$ppm\"}}" >/dev/null 2>&1 || true
+ if [ -s "$ppm" ] && command -v pnmtopng >/dev/null 2>&1; then
+ pnmtopng "$ppm" > "$png" 2>/dev/null && { rm -f "$ppm"; printf '%s\n' "$png"; return 0; }
+ fi
+ [ -s "$ppm" ] && { printf '%s\n' "$ppm"; return 0; }
+ return 1
+}
+
+vm_running() {
+ # True if the background VM (pidfile) or an interactive console VM (foreground,
+ # no pidfile) is running. The process check keeps `snapshot`/`stop` safe after
+ # an interactive `console-install`, which launches QEMU without a pidfile.
+ # Anchor on argv[0] (`^kit-qemu-win`) so this matches only the QEMU process,
+ # not management scripts whose command line happens to mention "kit-qemu-win".
+ if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then return 0; fi
+ pgrep -f '^kit-qemu-win' >/dev/null 2>&1
+}
+
+guest_shutdown() {
+ # Clean ACPI shutdown so the golden disk is not crash-consistent (avoids a
+ # chkdsk/repair pass on next boot). Prefer an in-guest shutdown, fall back to
+ # the ACPI power button over QMP.
+ local i
+ ssh_setup aarch64 2>/dev/null && \
+ ssh "${SSH_ARGS[@]}" "$SSH_DEST" "shutdown /s /t 0 /f" >/dev/null 2>&1 || true
+ qmp '{"execute":"system_powerdown"}' >/dev/null 2>&1 || true
+ for i in $(seq 1 120); do
+ vm_running || { rm -f "$PID_FILE"; return 0; }
+ sleep 2
+ done
+ return 1
+}
+
+powerdown() {
+ if vm_running; then
+ qmp '{"execute":"system_powerdown"}' >/dev/null 2>&1 || true
+ printf 'sent ACPI powerdown; waiting for shutdown...\n'
+ local i
+ for i in $(seq 1 60); do
+ vm_running || { printf 'vm stopped\n'; rm -f "$PID_FILE"; return 0; }
+ sleep 2
+ done
+ printf 'still running; killing\n'
+ kill "$(cat "$PID_FILE")" 2>/dev/null || true
+ else
+ printf 'no running vm\n'
+ fi
+ rm -f "$PID_FILE"
+}
+
+# ---------------------------------------------------------------------------
+# QEMU launch
+# ---------------------------------------------------------------------------
+
+base_qemu_args() {
+ # Emits the device set shared by install and normal boots.
+ local code
+ code="$(firmware_code)"
+ QEMU_ARGS=(
+ -name kit-win11-arm64
+ -L "$QEMU_SHARE"
+ -machine virt
+ -accel hvf
+ -cpu host
+ -m "$VM_MEM"
+ -smp "$VM_CPUS"
+ -drive "if=pflash,format=raw,readonly=on,file=$code"
+ -drive "if=pflash,format=raw,file=$VARS_PATH"
+ -device ramfb
+ -device "qemu-xhci,id=xhci"
+ -device usb-kbd
+ -device usb-tablet
+ -nic "user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:$VM_PORT-:22"
+ -drive "if=none,id=sysdisk,file=$DISK_PATH,format=qcow2,cache=writeback,discard=unmap"
+ -device "nvme,drive=sysdisk,serial=kitwin,bootindex=0"
+ -display "$DISPLAY_BACKEND"
+ -qmp "unix:$QMP_SOCK,server,nowait"
+ -serial "file:$VM_ROOT/run/serial.log"
+ )
+}
+
+launch_qemu() {
+ # launch_qemu : start QEMU in the background using the assembled QEMU_ARGS.
+ command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH"
+ mkdir -p "$VM_ROOT/run"
+ rm -f "$QMP_SOCK"
+ : > "$QEMU_LOG"
+ # Launch under a distinct argv[0] so a broad `pkill -f qemu-system-aarch64`
+ # (e.g. from a concurrent FreeBSD VM workflow sharing this repo) cannot match
+ # and kill this VM. QEMU resolves its data dir from the real executable path,
+ # not argv[0], and we pass -L explicitly, so the rename is invisible to QEMU.
+ ( exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}" ) >>"$QEMU_LOG" 2>&1 &
+ echo $! > "$PID_FILE"
+ printf 'qemu pid %s; log %s\n' "$(cat "$PID_FILE")" "$QEMU_LOG"
+ # Wait for the QMP socket so callers can drive the monitor.
+ local i
+ for i in $(seq 1 30); do
+ [ -S "$QMP_SOCK" ] && return 0
+ vm_running || { tail -20 "$QEMU_LOG" >&2; die "qemu exited during startup"; }
+ sleep 1
+ done
+ die "QMP socket did not appear"
+}
+
+# Attach the install + virtio + seed media as USB mass-storage (in-box usbstor
+# driver) so UEFI can boot the installer and Setup can read the seed + drivers.
+# The system NVMe disk is bootindex=0; the install CD is bootindex=1 so the empty
+# disk falls through to the CD on the first boot, but once Setup writes the
+# Windows Boot Manager to NVMe every later reboot boots the installed OS instead
+# of looping back into the installer.
+attach_install_media() {
+ local iso="$1" virtio="$2" seed="${3:-}"
+ QEMU_ARGS+=(
+ -device "usb-storage,drive=installcd,bootindex=1"
+ -drive "if=none,id=installcd,format=raw,media=cdrom,readonly=on,file=$iso"
+ -device "usb-storage,drive=virtiocd"
+ -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio"
+ )
+ [ -n "$seed" ] && QEMU_ARGS+=(
+ -device "usb-storage,drive=seedcd"
+ -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$seed"
+ )
+}
+
+install_vm() {
+ local iso virtio seed code
+ iso="$(win_iso)" || die "no Windows ISO; set KIT_WINDOWS_ISO or place Win11*Arm64*.iso in $CACHE_ROOT"
+ [ -f "$iso" ] || die "Windows ISO not found: $iso"
+ vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first"
+ if [ -f "$GOLDEN_PATH" ]; then
+ die "golden disk already exists ($GOLDEN_PATH); 'boot' to use it, 'reset' to revert to it, or remove it to reinstall"
+ fi
+
+ # Start each install from a fresh blank disk: a prior attempt may have left a
+ # half-installed disk, and autounattend wipes disk 0 regardless.
+ rm -f "$DISK_PATH"
+ create_disk
+ reset_firmware_vars
+ build_seed
+ virtio="$(virtio_iso)"
+ seed="$SEED_PATH"
+ [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)"
+
+ base_qemu_args
+ attach_install_media "$iso" "$virtio" "$seed"
+
+ printf 'installing Windows from: %s\n' "$iso"
+ printf ' edition : %s\n' "$WIN_EDITION"
+ printf ' virtio : %s\n' "$virtio"
+ printf ' seed : %s\n' "$seed"
+ printf ' ssh : %s@127.0.0.1:%s\n' "$SSH_USER" "$VM_PORT"
+ launch_qemu
+
+ # Dismiss the one-time "Press any key to boot from CD or DVD" prompt. Send Enter
+ # only until Setup starts writing the NVMe disk (proof it booted past the
+ # prompt), then stop — otherwise a later Enter lands on the install-progress
+ # screen's Cancel button and pops a "quit?" dialog. Watching disk growth makes
+ # this robust to a slow firmware POST without overshooting into Setup's UI.
+ ( local i sz
+ for i in $(seq 1 180); do
+ sz=$(stat -f %z "$DISK_PATH" 2>/dev/null || echo 0)
+ [ "$sz" -gt 2000000 ] && break
+ qmp '{"execute":"send-key","arguments":{"keys":[{"type":"qcode","data":"ret"}]}}' >/dev/null 2>&1 || true
+ sleep 2
+ done ) &
+
+ # Phase 1 (apply): Setup writes the image to the NVMe disk, then reboots and
+ # loops on the install CD. Detect "image applied + settled" by the disk size
+ # leveling off, then stop and hand off to firstboot_vm (which boots the
+ # installed OS without the install CD).
+ printf 'applying the Windows image (long); switching to first boot when done...\n'
+ local cur=0 last=-1 stable=0 i=0
+ while [ $i -lt 3600 ]; do
+ vm_running || die "qemu exited during image apply (see $QEMU_LOG)"
+ cur=$(stat -f %z "$DISK_PATH" 2>/dev/null || echo 0)
+ if [ "$cur" -gt 9000000000 ]; then
+ if [ "$cur" -eq "$last" ]; then stable=$((stable + 1)); else stable=0; fi
+ [ "$stable" -ge 3 ] && break
+ fi
+ last=$cur
+ [ $((i % 120)) -eq 0 ] && screenshot "apply-$i" >/dev/null 2>&1 || true
+ sleep 20
+ i=$((i + 20))
+ done
+ [ "$cur" -gt 9000000000 ] ||
+ die "image apply did not complete (disk only $cur bytes; see $SHOT_DIR and $QEMU_LOG)"
+ printf 'image applied (%s bytes on disk); stopping apply phase\n' "$cur"
+ kill "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null || true
+ for _ in $(seq 1 30); do vm_running || break; sleep 1; done
+ rm -f "$PID_FILE"
+ firstboot_vm
+}
+
+boot_vm() {
+ vm_running && { printf 'vm already running (pid %s)\n' "$(cat "$PID_FILE")"; return 0; }
+ if [ ! -f "$DISK_PATH" ]; then
+ [ -f "$GOLDEN_PATH" ] || die "no installed disk and no golden cache; run install first"
+ restore_golden
+ fi
+ ensure_firmware_vars
+ base_qemu_args
+ launch_qemu
+ printf 'booted installed VM (pid %s)\n' "$(cat "$PID_FILE")"
+}
+
+# Interactive install: open a real QEMU window and let the user drive Setup
+# (press a key at the boot prompt, click through any screens). Runs in the
+# foreground. The autounattend seed is still attached by default so the kit user,
+# OpenSSH, virtio NIC, and SSH key are configured automatically; set
+# KIT_WINDOWS_NO_SEED=1 for a fully manual install. When Windows is installed and
+# shut down, run `snapshot` to cache it as the golden disk.
+console_install() {
+ local iso virtio seed=""
+ iso="$(win_iso)" || die "no Windows ISO; set KIT_WINDOWS_ISO or place Win11*Arm64*.iso in $CACHE_ROOT"
+ [ -f "$iso" ] || die "Windows ISO not found: $iso"
+ vm_running && die "the headless VM is running (pid $(cat "$PID_FILE")); stop it first: scripts/windows_vm.sh stop"
+ if [ -f "$GOLDEN_PATH" ]; then
+ die "golden disk already exists ($GOLDEN_PATH); 'console' boots it, 'reset' reverts to it, or remove it to reinstall"
+ fi
+ command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH"
+ rm -f "$DISK_PATH"
+ create_disk
+ reset_firmware_vars
+ virtio="$(virtio_iso)"
+ [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)"
+ if [ -z "${KIT_WINDOWS_NO_SEED:-}" ]; then build_seed; seed="$SEED_PATH"; fi
+ DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-cocoa}"
+ mkdir -p "$VM_ROOT/run"
+ rm -f "$QMP_SOCK"
+ base_qemu_args
+ attach_install_media "$iso" "$virtio" "$seed"
+ cat <<MSG
+Interactive Windows install — a QEMU window will open.
+ * Press a key at "Press any key to boot from CD or DVD" to boot the installer.$(
+ [ -n "$seed" ] && printf '\n * The autounattend seed then drives Setup (kit user, OpenSSH, virtio NIC, SSH key).' \
+ || printf '\n * No seed attached: install Windows manually; SSH must be set up afterwards.')
+ * When Windows reaches the desktop, shut it down (Start > Power > Shut down).
+ * Then cache it as the golden disk: scripts/windows_vm.sh snapshot
+MSG
+ exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}"
+}
+
+# Open a window on the installed VM for manual inspection / interactive driving.
+# Runs in the foreground (cocoa). Before the install is finalized (no golden disk
+# yet) it boots like a first boot: reset NVRAM (so the firmware enumerates the
+# populated disk and boots Windows' ARM fallback loader) and attach the virtio +
+# seed media so the first-logon bootstrap can run or be inspected. Once a golden
+# disk exists it boots clean (no CDs, keeping NVRAM).
+console_boot() {
+ vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first"
+ command -v "$QEMU_BIN" >/dev/null 2>&1 || die "$QEMU_BIN not found on PATH"
+ DISPLAY_BACKEND="${KIT_WINDOWS_VM_DISPLAY:-cocoa}"
+ mkdir -p "$VM_ROOT/run"
+ rm -f "$QMP_SOCK"
+ if [ -f "$GOLDEN_PATH" ] || [ -f "$PROVISIONED_MARKER" ]; then
+ [ -f "$DISK_PATH" ] || restore_golden
+ ensure_firmware_vars
+ base_qemu_args
+ printf 'Opening a window on the installed VM. Close it or shut down the guest to exit.\n'
+ else
+ [ -f "$DISK_PATH" ] || die "no installed disk; run console-install first"
+ local virtio
+ virtio="$(virtio_iso)"
+ [ -f "$SEED_PATH" ] || build_seed
+ reset_firmware_vars
+ base_qemu_args
+ [ -f "$virtio" ] && QEMU_ARGS+=(
+ -device "usb-storage,drive=virtiocd"
+ -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio"
+ )
+ QEMU_ARGS+=(
+ -device "usb-storage,drive=seedcd"
+ -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$SEED_PATH"
+ )
+ printf 'Opening a window on the (not-yet-finalized) VM: fresh NVRAM + virtio/seed attached.\n'
+ printf 'Diagnostics inside the guest: C:\\kit-bootstrap.log, C:\\kit-ready.txt, services.msc (sshd), firewall.\n'
+ fi
+ exec -a kit-qemu-win "$QEMU_BIN" "${QEMU_ARGS[@]}"
+}
+
+# First boot of a freshly-installed disk. The Windows installer applies the image
+# and then reboots; if the bootable install CD is still in the boot path the
+# emulated firmware loops back into Setup instead of booting the new OS (its NVRAM
+# "Windows Boot Manager" entry does not persist under QEMU). So we reset NVRAM
+# (forcing a fresh enumeration that finds Windows' ARM fallback loader,
+# \EFI\Boot\bootaa64.efi) and boot WITHOUT the install CD but WITH the virtio +
+# seed media, so the FirstLogon bootstrap can install the NIC driver + OpenSSH.
+# On success the disk is cleanly shut down and cached as the golden disk.
+firstboot_vm() {
+ local virtio
+ vm_running && die "a VM is already running (pid $(cat "$PID_FILE")); stop it first"
+ [ -f "$DISK_PATH" ] || die "no installed disk; run install or console-install first"
+ [ -f "$GOLDEN_PATH" ] && die "golden disk already exists ($GOLDEN_PATH); use boot/reset"
+ virtio="$(virtio_iso)"
+ [ -f "$virtio" ] || die "virtio-win ISO missing: $virtio (run fetch-virtio)"
+ [ -f "$SEED_PATH" ] || build_seed
+ reset_firmware_vars
+ base_qemu_args
+ QEMU_ARGS+=(
+ -device "usb-storage,drive=virtiocd"
+ -drive "if=none,id=virtiocd,format=raw,media=cdrom,readonly=on,file=$virtio"
+ -device "usb-storage,drive=seedcd"
+ -drive "if=none,id=seedcd,format=raw,media=cdrom,readonly=on,file=$SEED_PATH"
+ )
+ printf 'first boot (no install CD): finishing setup + first-logon bootstrap...\n'
+ launch_qemu
+ wait_ssh 2400
+ printf 'bootstrap complete; shutting down cleanly to cache the golden disk...\n'
+ if guest_shutdown; then
+ cache_golden
+ else
+ printf 'WARNING: clean shutdown timed out; golden NOT cached.\n'
+ printf 'Run: scripts/windows_vm.sh stop && scripts/windows_vm.sh snapshot\n'
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# SSH plumbing (also used by run/smoke)
+# ---------------------------------------------------------------------------
+
vm_dest() {
case "$(canon_arch "$1")" in
x64)
- if [ -n "${KIT_WINDOWS_VM_X64:-}" ]; then
- printf '%s\n' "$KIT_WINDOWS_VM_X64"
- else
- printf '%s\n' "${KIT_WINDOWS_VM_AMD64:-}"
- fi
- ;;
+ if [ -n "${KIT_WINDOWS_VM_X64:-${KIT_WINDOWS_VM_AMD64:-}}" ]; then
+ printf '%s\n' "${KIT_WINDOWS_VM_X64:-$KIT_WINDOWS_VM_AMD64}"; return 0
+ fi ;;
aarch64)
- if [ -n "${KIT_WINDOWS_VM_AARCH64:-}" ]; then
- printf '%s\n' "$KIT_WINDOWS_VM_AARCH64"
- else
- printf '%s\n' "${KIT_WINDOWS_VM_ARM64:-}"
- fi
- ;;
+ if [ -n "${KIT_WINDOWS_VM_AARCH64:-${KIT_WINDOWS_VM_ARM64:-}}" ]; then
+ printf '%s\n' "${KIT_WINDOWS_VM_AARCH64:-$KIT_WINDOWS_VM_ARM64}"; return 0
+ fi ;;
esac
+ # Fall back to the locally provisioned VM (one VM serves both arches).
+ if [ -f "$DISK_PATH" ]; then printf '%s@127.0.0.1\n' "$SSH_USER"; fi
}
vm_port() {
case "$(canon_arch "$1")" in
- x64)
- if [ -n "${KIT_WINDOWS_VM_X64_PORT:-}" ]; then
- printf '%s\n' "$KIT_WINDOWS_VM_X64_PORT"
- else
- printf '%s\n' "${KIT_WINDOWS_VM_AMD64_PORT:-}"
- fi
- ;;
- aarch64)
- if [ -n "${KIT_WINDOWS_VM_AARCH64_PORT:-}" ]; then
- printf '%s\n' "$KIT_WINDOWS_VM_AARCH64_PORT"
- else
- printf '%s\n' "${KIT_WINDOWS_VM_ARM64_PORT:-}"
- fi
- ;;
+ x64) [ -n "${KIT_WINDOWS_VM_X64_PORT:-${KIT_WINDOWS_VM_AMD64_PORT:-}}" ] &&
+ { printf '%s\n' "${KIT_WINDOWS_VM_X64_PORT:-$KIT_WINDOWS_VM_AMD64_PORT}"; return 0; } ;;
+ aarch64) [ -n "${KIT_WINDOWS_VM_AARCH64_PORT:-${KIT_WINDOWS_VM_ARM64_PORT:-}}" ] &&
+ { printf '%s\n' "${KIT_WINDOWS_VM_AARCH64_PORT:-$KIT_WINDOWS_VM_ARM64_PORT}"; return 0; } ;;
esac
+ if [ -f "$DISK_PATH" ]; then printf '%s\n' "$VM_PORT"; fi
}
ssh_setup() {
- local arch="$1" port key opts
- SSH_DEST="$(vm_dest "$arch")"
- [ -n "$SSH_DEST" ] || die "no VM configured for $(canon_arch "$arch")"
+ local arch="$1" port key opts dest
+ dest="$(vm_dest "$arch")"
+ [ -n "$dest" ] || die "no VM configured for $(canon_arch "$arch")"
+ SSH_DEST="$dest"
SSH_ARGS=()
port="$(vm_port "$arch")"
key="${KIT_WINDOWS_VM_SSH_KEY:-}"
+ if [ -z "$key" ] && [ -f "$SSH_KEY_DEFAULT" ]; then key="$SSH_KEY_DEFAULT"; fi
opts="${KIT_WINDOWS_VM_SSH_OPTS:-}"
if [ -n "$opts" ]; then
- # Intentional word-splitting: this is a user-provided ssh option string.
+ # Intentional word-splitting of a user-provided ssh option string.
# shellcheck disable=SC2206
SSH_ARGS=($opts)
fi
- if [ -n "$key" ]; then
- SSH_ARGS=("${SSH_ARGS[@]}" -i "$key")
- fi
- if [ -n "$port" ]; then
- SSH_ARGS=("${SSH_ARGS[@]}" -p "$port")
- fi
- SSH_ARGS=("${SSH_ARGS[@]}" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
+ [ -n "$key" ] && SSH_ARGS=("${SSH_ARGS[@]}" -i "$key")
+ # Use -o Port= (not -p) so SSH_ARGS works verbatim for both ssh and scp.
+ [ -n "$port" ] && SSH_ARGS=("${SSH_ARGS[@]}" -o "Port=$port")
+ SSH_ARGS=("${SSH_ARGS[@]}"
+ -o BatchMode=yes
+ -o StrictHostKeyChecking=no
+ -o UserKnownHostsFile=/dev/null
+ -o LogLevel=ERROR
+ -o ConnectTimeout=8)
}
-ps_sq() {
- printf '%s' "$1" | sed "s/'/''/g"
+remote_ps() {
+ # Send the PowerShell program as -EncodedCommand (UTF-16LE base64). The guest's
+ # default ssh shell is cmd, and ssh flattens the remote argv into one string, so
+ # a bare -Command "<script>" lets cmd mis-parse the script's pipes/quotes (e.g.
+ # `| Out-Null`). Base64 has no shell-special characters, so it travels intact.
+ # stdin (used by the upload step) still flows through to the PowerShell process.
+ local enc full
+ # Silence the progress stream so it does not show up as CLIXML noise on stderr.
+ full='$ProgressPreference = "SilentlyContinue"; '"$1"
+ enc=$(printf '%s' "$full" | iconv -t UTF-16LE | base64 | tr -d '\n')
+ ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$POWERSHELL" -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$enc"
}
-b64_one_line() {
- base64 "$1" | tr -d '\n'
-}
-
-b64_arg() {
- printf '%s' "$1" | base64 | tr -d '\n'
-}
+ps_sq() { printf '%s' "$1" | sed "s/'/''/g"; }
+b64_arg() { printf '%s' "$1" | base64 | tr -d '\n'; }
ps_arg_array() {
local first=1 arg enc
@@ -139,21 +774,13 @@ ps_env_assignments() {
local names name val
names="${KIT_WINDOWS_VM_ENV_VARS:-KIT_WIN_PROBE}"
for name in $names; do
- val="$(env_get "$name")"
if [ -n "${!name+x}" ]; then
+ val="${!name}"
printf '$env:%s = '\''%s'\''; ' "$name" "$(ps_sq "$val")"
fi
done
}
-remote_ps() {
- ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$POWERSHELL" -NoProfile -ExecutionPolicy Bypass -Command "$1"
-}
-
-remote_ps_stdin() {
- ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$POWERSHELL" -NoProfile -ExecutionPolicy Bypass -Command "$1"
-}
-
remote_mkdir() {
local token ps
token="kit-vm-$(date +%Y%m%d%H%M%S)-$$-$RANDOM"
@@ -169,18 +796,21 @@ remote_cleanup() {
}
run_exe() {
- local arch="$1" exe="$2" destdir base upload_ps run_ps args_ps env_ps rc
+ local arch="$1" exe="$2" destdir base dest_fwd run_ps args_ps env_ps rc
shift 2
[ -f "$exe" ] || die "exe not found: $exe"
command -v ssh >/dev/null 2>&1 || die "ssh not found"
+ command -v scp >/dev/null 2>&1 || die "scp not found"
command -v base64 >/dev/null 2>&1 || die "base64 not found"
ssh_setup "$arch"
destdir="$(remote_mkdir)"
base="$(basename "$exe")"
- upload_ps="\$ErrorActionPreference='Stop'; \$p=Join-Path '$(ps_sq "$destdir")' '$(ps_sq "$base")'; \$b=[Console]::In.ReadToEnd(); [IO.File]::WriteAllBytes(\$p, [Convert]::FromBase64String(\$b))"
- if ! b64_one_line "$exe" | remote_ps_stdin "$upload_ps"; then
+ # Upload over scp (Win32-OpenSSH ships the sftp subsystem). scp needs a
+ # forward-slash remote path; the run step below uses the native backslash path.
+ dest_fwd="${destdir//\\//}/$base"
+ if ! scp "${SSH_ARGS[@]}" "$exe" "$SSH_DEST:$dest_fwd" >/dev/null 2>&1; then
remote_cleanup "$destdir"
- return 1
+ die "scp upload failed: $exe -> $dest_fwd"
fi
args_ps="$(ps_arg_array "$@")"
env_ps="$(ps_env_assignments)"
@@ -200,23 +830,98 @@ smoke_arch() {
remote_ps "$ps"
}
+wait_ssh() {
+ local timeout="${1:-600}" arch=aarch64 i=0 shot
+ ssh_setup "$arch"
+ printf 'waiting for ssh on %s (port %s), up to %ss\n' "$SSH_DEST" "$(vm_port "$arch")" "$timeout"
+ i=0
+ while [ "$i" -lt "$timeout" ]; do
+ # Probe with a bare single-token command: Windows sshd wraps the remote
+ # command in `cmd /c "..."`, which mangles a nested `cmd /c ver` into a
+ # dangling quote. A lone builtin like `ver` round-trips cleanly.
+ if ssh "${SSH_ARGS[@]}" "$SSH_DEST" ver 2>/dev/null; then
+ printf 'ssh ready after ~%ss\n' "$i"
+ remote_ps "[Console]::WriteLine('arch=' + \$env:PROCESSOR_ARCHITECTURE)" 2>/dev/null || true
+ return 0
+ fi
+ if ! vm_running; then die "qemu exited while waiting for ssh (see $QEMU_LOG)"; fi
+ # Capture a screenshot every ~60s so a stalled install is debuggable.
+ if [ $((i % 60)) -eq 0 ]; then
+ shot="$(screenshot "wait-$i" 2>/dev/null || true)"
+ [ -n "$shot" ] && printf ' [%ss] screenshot: %s\n' "$i" "$shot"
+ fi
+ sleep 10
+ i=$((i + 10))
+ done
+ screenshot "wait-timeout" >/dev/null 2>&1 || true
+ die "ssh did not become ready within ${timeout}s (see $QEMU_LOG and $SHOT_DIR)"
+}
+
+ssh_arch() {
+ local arch=aarch64
+ if [ $# -ge 1 ] && case "${1:-}" in x64|x86_64|amd64|aarch64|arm64|aa64) true ;; *) false ;; esac; then
+ arch="$1"; shift
+ fi
+ ssh_setup "$arch"
+ if [ $# -eq 0 ]; then
+ exec ssh "${SSH_ARGS[@]}" "$SSH_DEST"
+ fi
+ exec ssh "${SSH_ARGS[@]}" "$SSH_DEST" "$@"
+}
+
+# ---------------------------------------------------------------------------
+# doctor / status
+# ---------------------------------------------------------------------------
+
doctor() {
+ local iso
printf 'host: %s/%s\n' "$(uname -s 2>/dev/null)" "$(uname -m 2>/dev/null)"
printf 'repo: %s\n' "$ROOT"
- for tool in ssh base64 sed; do
+ printf 'vm dir: %s\n' "$VM_ROOT"
+ printf 'cache: %s\n' "$CACHE_ROOT"
+ printf 'hvf: %s\n' "$(sysctl -n kern.hv_support 2>/dev/null || echo '?')"
+ for tool in "$QEMU_BIN" qemu-img ssh base64 hdiutil python3 pnmtopng curl; 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
- printf ' x64 dest=%s port=%s\n' "$(vm_dest x64)" "$(vm_port x64)"
- printf ' aarch64 dest=%s port=%s\n' "$(vm_dest aarch64)" "$(vm_port aarch64)"
+ printf 'firmware:\n'
+ printf ' %-7s %s\n' code "$(firmware_code)$([ -f "$(firmware_code)" ] && echo '' || echo ' (MISSING)')"
+ printf ' %-7s %s\n' vars "$(firmware_vars_template)$([ -f "$(firmware_vars_template)" ] && echo '' || echo ' (MISSING)')"
+ printf 'media:\n'
+ 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
+ printf ' virtio %s%s\n' "$(virtio_iso)" "$([ -f "$(virtio_iso)" ] && echo '' || echo ' (run fetch-virtio)')"
+ printf ' openssh %s%s\n' "$(openssh_zip)" "$([ -f "$(openssh_zip)" ] && echo '' || echo ' (run fetch-openssh)')"
+ printf 'artifacts:\n'
+ printf ' disk %s%s\n' "$DISK_PATH" "$([ -f "$DISK_PATH" ] && echo '' || echo ' (not created)')"
+ printf ' golden %s%s\n' "$GOLDEN_PATH" "$([ -f "$GOLDEN_PATH" ] && echo ' (installed)' || echo ' (none; run install)')"
+ printf ' seed %s%s\n' "$SEED_PATH" "$([ -f "$SEED_PATH" ] && echo '' || echo ' (not built)')"
+ printf ' ssh key %s%s\n' "$SSH_KEY_DEFAULT" "$([ -f "$SSH_KEY_DEFAULT" ] && echo '' || echo ' (not generated)')"
+ printf 'vm: %s\n' "$(vm_running && echo "running (pid $(cat "$PID_FILE"))" || echo 'stopped')"
+ printf ' x64 dest=%s port=%s\n' "$(vm_dest x64)" "$(vm_port x64)"
+ printf ' aarch64 dest=%s port=%s\n' "$(vm_dest aarch64)" "$(vm_port aarch64)"
}
cmd="${1:-}"
case "$cmd" in
doctor) doctor ;;
+ fetch-virtio) fetch_virtio ;;
+ fetch-openssh) fetch_openssh ;;
+ seed) build_seed ;;
+ prepare) prepare ;;
+ install) install_vm ;;
+ console-install) console_install ;;
+ console) console_boot ;;
+ firstboot) firstboot_vm ;;
+ boot) boot_vm ;;
+ wait-ssh) wait_ssh "${2:-600}" ;;
+ ssh) shift; ssh_arch "$@" ;;
+ screenshot) screenshot "${2:-shot}" ;;
+ snapshot) cache_golden ;;
+ reset) restore_golden ;;
+ stop) powerdown ;;
smoke) [ $# -eq 2 ] || { usage; exit 2; }; smoke_arch "$2" ;;
run) [ $# -ge 3 ] || { usage; exit 2; }; arch="$2"; exe="$3"; shift 3; run_exe "$arch" "$exe" "$@" ;;
-h|--help|help|"") usage ;;