kit

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

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:
Mdoc/WINDOWS.md | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mmk/test.mk | 22++++++++++++++++++++++
Ascripts/win/autounattend.xml.in | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/win/bootstrap.ps1.in | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/windows_vm.sh | 867+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
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 ;;