diff --git a/docs/phase4-platform-acceptance.md b/docs/phase4-platform-acceptance.md index 4ce2860..0799337 100644 --- a/docs/phase4-platform-acceptance.md +++ b/docs/phase4-platform-acceptance.md @@ -120,6 +120,21 @@ site-equivalent path without touching the public website. It creates an unrelated WSL control distribution and proves uninstall retains it. It never claims Windows artifact creation or signing. +### Retained host provisioning + +`ops/platform-acceptance/windows-host/` tracks the bootstrap media for this host: +the answer file, the first-boot driver/guest-agent/OpenSSH setup, and a +reproducible ISO build. Its README records two host constraints that are easy to +get wrong. Proxmox always creates the TPM state volume as raw, and a raw volume +on directory storage blocks snapshots for the entire VM even when every other +disk is qcow2 — so the acceptance host cannot honour "restore the accepted clean +snapshot before routing" unless that small volume sits on snapshot-capable +storage. Verify with `qm snapshot probe` before provisioning. Separately, +Windows 11 25H2 (build 26200) does not auto-apply an answer file from secondary +media and ignores `setup.exe /unattend:`; the README documents the offline +`dism /apply-image` path that still runs the specialize, oobeSystem and +auditUser passes. + ## Recovery and teardown First disable the applicable repository variable so new jobs cannot route. diff --git a/ops/platform-acceptance/windows-host/.gitignore b/ops/platform-acceptance/windows-host/.gitignore new file mode 100644 index 0000000..54db5f1 --- /dev/null +++ b/ops/platform-acceptance/windows-host/.gitignore @@ -0,0 +1,2 @@ +# The per-site operator public key is never committed to this public repository. +authorized_key.pub diff --git a/ops/platform-acceptance/windows-host/Autounattend.xml b/ops/platform-acceptance/windows-host/Autounattend.xml new file mode 100644 index 0000000..a5a6598 --- /dev/null +++ b/ops/platform-acceptance/windows-host/Autounattend.xml @@ -0,0 +1,50 @@ + + + + + en-US + en-USen-USen-USen-US + + + + E:\vioscsi\w11\amd64 + E:\NetKVM\w11\amd64 + + + + + 0true + + 1EFI260 + 2MSR16 + 3Primarytrue + + + 11FAT32 + 23NTFS + + + + /IMAGE/INDEX603 + true1Helm1Helm acceptance + falseOnError + + + + + ONEHELM-WINUTC1Helm1Helm acceptance + + + + + Audit + + + + + + 1Provision retained 1Helm acceptance hostpowershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$v=(Get-Volume -FileSystemLabel ONEHELM); & ($v.DriveLetter + ':\setup.ps1')"Never + + + + diff --git a/ops/platform-acceptance/windows-host/README.md b/ops/platform-acceptance/windows-host/README.md new file mode 100644 index 0000000..8a207d5 --- /dev/null +++ b/ops/platform-acceptance/windows-host/README.md @@ -0,0 +1,75 @@ +# Retained Windows acceptance-host provisioning + +These files build the bootstrap ISO for the dedicated Windows 11 Phase 4 +acceptance host described in `docs/phase4-platform-acceptance.md`. They were +previously carried only as a pre-built ISO on the hypervisor, so a rebuild could +not be reviewed or reproduced. They are tracked here for that reason. + +Nothing here publishes a release, creates a tag, deploys the website, or touches +production data or services. + +## What it does + +`Autounattend.xml` partitions the disk (GPT, EFI + MSR + NTFS), installs the Pro +image, sets the computer name and UTC timezone, then reseals into **audit mode** +and runs `setup.ps1` during the `auditUser` pass. + +`setup.ps1` installs the VirtIO network and serial drivers plus the QEMU guest +agent, enables OpenSSH Server pinned to the operator's public key (supplied at build +time, see below) with password authentication off, disables sleep and hibernate, and records +`C:\1HelmAcceptance\ready.json`. It is idempotent: an existing `ready.json` +makes it exit 0 immediately. + +## Build + +```sh +./build-unattend-iso.sh 1helm-windows-unattend.iso /path/to/operator_key.pub +``` + +The operator public key is **not** committed: this repository is public, and +publishing which key is authorized as Administrator on the acceptance host is +needless disclosure. Pass it at build time (or via +`HELM_ACCEPTANCE_AUTHORIZED_KEY`); the build stages it as `authorized_key.pub` +on the ISO and `setup.ps1` refuses to continue without it. `.gitignore` keeps a +local copy out of git. + +Two constraints are load-bearing: + +- `-iso-level 4` — at genisoimage's default level the ISO9660 namespace + truncates `Autounattend.xml` to `AUTOUNAT.XML`, which Windows Setup does not + recognize as an answer file. +- Volume label `ONEHELM` — the `auditUser` pass locates `setup.ps1` with + `Get-Volume -FileSystemLabel ONEHELM`. + +## Host VM shape + +The acceptance workflow restores an accepted clean snapshot before every job, so +the VM must actually be able to snapshot. Proxmox always creates the **TPM state +volume as raw**, and a raw volume on directory storage blocks snapshots for the +whole VM even when every other disk is qcow2. Place the disks as qcow2 and put +the small `tpmstate0` volume on snapshot-capable storage (thin-LVM), which keeps +TPM 2.0 and Secure Boot intact so no Windows 11 requirement bypass is needed. + +Verify before installing anything: + +```sh +qm snapshot probe && qm delsnapshot probe +``` + +## Windows 11 25H2 answer-file caveat + +On build 26200 (25H2) the new setup engine (`setuphost.exe`) does **not** +auto-apply `Autounattend.xml` from a secondary disc, and `setup.exe /unattend:` +is ignored. The answer file is readable from WinPE — it simply is not consumed, +so setup falls through to the interactive product-key page. + +Working alternative on affected media: from the WinPE shell (Shift+F10), +partition with `diskpart`, apply the image with +`dism /apply-image /imagefile::\sources\install.wim /index:6 /applydir:W:\`, +copy `Autounattend.xml` to `W:\Windows\Panther\unattend.xml`, then +`bcdboot W:\Windows /s S: /f UEFI`. First boot still runs `specialize`, +`oobeSystem` and `auditUser`, so the computer name, audit reseal and +`setup.ps1` all still apply. + +Note that WinPE on this media has no `curl`, no `taskkill`, and no configured +network, so stage anything you need on the bootstrap ISO itself. diff --git a/ops/platform-acceptance/windows-host/build-unattend-iso.sh b/ops/platform-acceptance/windows-host/build-unattend-iso.sh new file mode 100755 index 0000000..3921786 --- /dev/null +++ b/ops/platform-acceptance/windows-host/build-unattend-iso.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Build the retained Windows acceptance-host bootstrap ISO. +# +# Attach the result as a second CD-ROM alongside the Windows installation media +# on the dedicated Phase 4 acceptance VM. It carries the answer file and the +# first-boot bootstrap; it publishes nothing and contains no credentials beyond +# the operator's own SSH public key in setup.ps1. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +out="${1:-1helm-windows-unattend.iso}" +key="${2:-${HELM_ACCEPTANCE_AUTHORIZED_KEY:-}}" + +if [ -z "$key" ] || [ ! -f "$key" ]; then + echo "usage: build-unattend-iso.sh [out.iso] " >&2 + echo " or: HELM_ACCEPTANCE_AUTHORIZED_KEY=/path/to/key.pub build-unattend-iso.sh [out.iso]" >&2 + echo "The operator public key is deliberately not committed to this public repository." >&2 + exit 2 +fi + +stage="$(mktemp -d)" +trap 'rm -rf "$stage"' EXIT + +cp "$here/Autounattend.xml" "$here/setup.ps1" "$stage/" +printf 'Retained Windows acceptance bootstrap for 1Helm.\n' > "$stage/1helm-acceptance.txt" +install -m 0644 "$key" "$stage/authorized_key.pub" + +# -iso-level 4 keeps long file names. At genisoimage's default level the +# ISO9660 namespace truncates Autounattend.xml to AUTOUNAT.XML, which Windows +# Setup does not recognize as an answer file. +# +# The volume label MUST remain ONEHELM: the auditUser pass in Autounattend.xml +# locates setup.ps1 via Get-Volume -FileSystemLabel ONEHELM. +genisoimage -quiet -iso-level 4 -J -r -V ONEHELM -o "$out" "$stage" + +echo "wrote $out" diff --git a/ops/platform-acceptance/windows-host/setup.ps1 b/ops/platform-acceptance/windows-host/setup.ps1 new file mode 100644 index 0000000..6698eeb --- /dev/null +++ b/ops/platform-acceptance/windows-host/setup.ps1 @@ -0,0 +1,62 @@ +$ErrorActionPreference = "Stop" +$root = "C:\1HelmAcceptance" +New-Item -ItemType Directory -Force -Path $root | Out-Null +$log = Join-Path $root "bootstrap.log" +Start-Transcript -Path $log -Append +try { + if (Test-Path (Join-Path $root "ready.json")) { exit 0 } + + $virtio = Get-Volume | Where-Object { $_.DriveLetter -and (Test-Path ("{0}:\guest-agent\qemu-ga-x86_64.msi" -f $_.DriveLetter)) } | Select-Object -First 1 + if (-not $virtio) { throw "VirtIO 0.1.271 media was not found." } + $drive = "{0}:" -f $virtio.DriveLetter + & pnputil.exe /add-driver "$drive\NetKVM\w11\amd64\*.inf" /subdirs /install + if ($LASTEXITCODE -notin 0, 3010) { throw "NetKVM driver install failed: $LASTEXITCODE" } + # The QEMU guest agent reaches the host over a VirtIO serial port. Without + # this driver the QEMU-GA service still starts and reports Running, while the + # host side ("qm agent ping") stays dead and there is no guest-exec + # channel to provision the host with. + & pnputil.exe /add-driver "$drive\vioserial\w11\amd64\*.inf" /subdirs /install + if ($LASTEXITCODE -notin 0, 3010) { throw "VirtIO serial driver install failed: $LASTEXITCODE" } + & msiexec.exe /i "$drive\guest-agent\qemu-ga-x86_64.msi" /qn /norestart + if ($LASTEXITCODE -notin 0, 3010) { throw "QEMU guest agent install failed: $LASTEXITCODE" } + + $capability = Get-WindowsCapability -Online | Where-Object Name -Like "OpenSSH.Server*" | Select-Object -First 1 + if (-not $capability) { throw "Windows did not expose the OpenSSH Server capability." } + if ($capability.State -ne "Installed") { Add-WindowsCapability -Online -Name $capability.Name | Out-Null } + + New-Item -ItemType Directory -Force -Path "C:\ProgramData\ssh" | Out-Null + # The authorized key is supplied per site on the bootstrap media rather than + # committed to this public repository. build-unattend-iso.sh stages it. + $keySource = Join-Path $PSScriptRoot "authorized_key.pub" + if (-not (Test-Path $keySource)) { throw "authorized_key.pub is missing from the bootstrap media; see ops/platform-acceptance/windows-host/README.md" } + $authorizedKey = (Get-Content -Raw $keySource).Trim() + if ($authorizedKey -notmatch '^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp[0-9]+) ') { throw "authorized_key.pub is not an OpenSSH public key" } + Set-Content -Encoding ascii -Path "C:\ProgramData\ssh\administrators_authorized_keys" -Value $authorizedKey + & icacls.exe "C:\ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "Administrators:F" /grant "SYSTEM:F" | Out-Null + $config = "C:\ProgramData\ssh\sshd_config" + if (Test-Path $config) { + $text = Get-Content -Raw $config + $text = [regex]::Replace($text, '(?m)^\s*#?\s*PasswordAuthentication\s+.*$', 'PasswordAuthentication no') + $text = [regex]::Replace($text, '(?m)^\s*#?\s*PubkeyAuthentication\s+.*$', 'PubkeyAuthentication yes') + Set-Content -Encoding ascii -Path $config -Value $text + } + Set-Service sshd -StartupType Automatic + Start-Service sshd + # Add-WindowsCapability already creates OpenSSH-Server-In-TCP, but scoped to + # the Private profile only. A freshly bridged VM is categorized Public, so a + # bare existence check short-circuits and leaves port 22 unreachable even + # though sshd reports Running. Ensure the rule exists AND covers every profile. + if (Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue) { + Set-NetFirewallRule -Name OpenSSH-Server-In-TCP -Enabled True -Profile Any + } else { + New-NetFirewallRule -Name OpenSSH-Server-In-TCP -DisplayName "OpenSSH Server (sshd)" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 -Profile Any | Out-Null + } + Set-Service QEMU-GA -StartupType Automatic + Start-Service QEMU-GA + powercfg.exe /change standby-timeout-ac 0 | Out-Null + powercfg.exe /change hibernate-timeout-ac 0 | Out-Null + $record = [ordered]@{ ready = $true; computer = $env:COMPUTERNAME; build = [Environment]::OSVersion.Version.ToString(); at = (Get-Date).ToUniversalTime().ToString("o") } + Set-Content -Encoding utf8 -Path (Join-Path $root "ready.json") -Value ($record | ConvertTo-Json -Compress) +} finally { + Stop-Transcript +}