From 4a1fb55ad2fab8ef96fa4a9be2cae308ed823a5b Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Wed, 5 Aug 2026 03:09:00 +0000 Subject: [PATCH 1/2] fix(phase4): finish Mac and Windows acceptance assertions The Mac app exposes multiple loopback listeners. Acceptance exited on the first lsof miss under set -e and, once a listener appeared, probed only the first port even when it was not the setup-health API. Poll every candidate listener safely until one returns setup health, and retain the exact failing line in the job log. The Windows script used shell-style escaping inside PowerShell strings, which executed systemctl on Windows after a successful WSL install. Replace all four copies with one literal, semver-bounded WSL assertion helper. Add focused contract assertions for both regressions. The full real Mac flow was also exercised on the dedicated helm-ci account: clean install, prior Stable, candidate updater, loopback health, and state retention all passed. Co-Authored-By: Claude --- ops/platform-acceptance/macos.sh | 42 ++++++++++++++++------------- ops/platform-acceptance/windows.ps1 | 21 ++++++++++++--- test/phase4-platform-acceptance.mjs | 7 +++++ 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/ops/platform-acceptance/macos.sh b/ops/platform-acceptance/macos.sh index f2421dd..c8bc2e8 100755 --- a/ops/platform-acceptance/macos.sh +++ b/ops/platform-acceptance/macos.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +# Preserve exact artifact checks while making silent `[[ ... ]]` failures +# diagnosable from the job log. Without this, a failed launch/version/state +# assertion surfaces only as "exit code 1" after minutes of signature output. +trap 'status=$?; echo "::error::macOS acceptance failed at line ${LINENO} (exit ${status}): ${BASH_COMMAND}" >&2' ERR + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DOWNLOAD="${HELM_MAC_CANDIDATE_DOWNLOAD:?exact Mac candidate directory is required}" OUTPUT="${HELM_ACCEPTANCE_OUTPUT:?acceptance output is required}" @@ -27,6 +32,22 @@ node "$ROOT/scripts/pending-acceptance-evidence.mjs" artifact_field() { node -p 'const m=JSON.parse(require("fs").readFileSync(process.argv[1])); const a=m.artifacts.find(x=>x.role===process.argv[2]); if(!a)process.exit(2); a[process.argv[3]]' "$MANIFEST" "$1" "$2" } +wait_for_setup_health() { + local output="$1" port + for _ in {1..180}; do + while IFS= read -r port; do + [[ "$port" =~ ^[0-9]+$ ]] || continue + if curl -fsS "http://127.0.0.1:$port/api/setup/status" >"$output.tmp"; then + mv "$output.tmp" "$output" + return 0 + fi + rm -f -- "$output.tmp" + done < <(lsof -nP -a -u "$(id -un)" -c 1Helm -iTCP -sTCP:LISTEN 2>/dev/null \ + | awk '/127\.0\.0\.1:/ {split($9,a,":"); print a[length(a)]}' || true) + sleep 1 + done + return 1 +} DMG_SHA="$(artifact_field mac_dmg sha256)"; ZIP_SHA="$(artifact_field mac_updater_zip sha256)" [[ "$(shasum -a 256 "$DMG" | awk '{print $1}')" == "$DMG_SHA" ]] [[ "$(shasum -a 256 "$ZIP" | awk '{print $1}')" == "$ZIP_SHA" ]] @@ -68,12 +89,7 @@ spctl --assess --type execute --verbose=4 "$work/update/1Helm.app" mkdir -p "$DATA_ROOT" printf '%s\n' server >"$DATA_ROOT/desktop-mode" open -n "$installed" --args --1helm-background -for _ in {1..180}; do - PORT="$(lsof -nP -a -u "$(id -un)" -c 1Helm -iTCP -sTCP:LISTEN 2>/dev/null | awk '/127\.0\.0\.1:/ {split($9,a,":"); print a[length(a)]; exit}')" - [[ "$PORT" =~ ^[0-9]+$ ]] && curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >"$work/clean-health.json" && break - sleep 1 -done -[[ -s "$work/clean-health.json" ]] +wait_for_setup_health "$work/clean-health.json" osascript -e 'tell application id "com.gitcommit90.1helm" to quit' || true for _ in {1..30}; do pgrep -x -U "$(id -u)" 1Helm >/dev/null || break; sleep 1; done ! pgrep -x -U "$(id -u)" 1Helm >/dev/null @@ -122,12 +138,7 @@ printf '%s\n' server >"$DATA_ROOT/desktop-mode" printf '%s\n' "phase4-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" >"$DATA_ROOT/phase4-acceptance-state" STATE_BEFORE="$(shasum -a 256 "$DATA_ROOT/phase4-acceptance-state" | awk '{print $1}')" open -n "$installed" --args --1helm-background -for _ in {1..180}; do - PORT="$(lsof -nP -a -u "$(id -un)" -c 1Helm -iTCP -sTCP:LISTEN 2>/dev/null | awk '/127\.0\.0\.1:/ {split($9,a,":"); print a[length(a)]; exit}')" - [[ "$PORT" =~ ^[0-9]+$ ]] && curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >"$work/prior-health.json" && break - sleep 1 -done -[[ -s "$work/prior-health.json" ]] +wait_for_setup_health "$work/prior-health.json" osascript -e 'tell application id "com.gitcommit90.1helm" to quit' || true for _ in {1..30}; do pgrep -x -U "$(id -u)" 1Helm >/dev/null || break; sleep 1; done ! pgrep -x -U "$(id -u)" 1Helm >/dev/null @@ -135,12 +146,7 @@ rm -rf -- "$installed" ditto "$work/update/1Helm.app" "$installed" [[ "$(defaults read "$installed/Contents/Info" CFBundleShortVersionString)" == "$VERSION" ]] open -n "$installed" --args --1helm-background -for _ in {1..180}; do - PORT="$(lsof -nP -a -u "$(id -un)" -c 1Helm -iTCP -sTCP:LISTEN 2>/dev/null | awk '/127\.0\.0\.1:/ {split($9,a,":"); print a[length(a)]; exit}')" - [[ "$PORT" =~ ^[0-9]+$ ]] && curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >"$work/update-health.json" && break - sleep 1 -done -[[ -s "$work/update-health.json" ]] +wait_for_setup_health "$work/update-health.json" STATE_AFTER="$(shasum -a 256 "$DATA_ROOT/phase4-acceptance-state" | awk '{print $1}')" [[ "$STATE_BEFORE" == "$STATE_AFTER" ]] osascript -e 'tell application id "com.gitcommit90.1helm" to quit' || true diff --git a/ops/platform-acceptance/windows.ps1 b/ops/platform-acceptance/windows.ps1 index 33807b8..aa0836f 100644 --- a/ops/platform-acceptance/windows.ps1 +++ b/ops/platform-acceptance/windows.ps1 @@ -32,6 +32,19 @@ function Invoke-Distro([string] $Command) { & $Wsl -d $Distro -u root --exec /bin/bash -lc $Command | Out-Host if ($LASTEXITCODE -ne 0) { Refuse "in-distribution command failed: $Command" } } +function Assert-DistroVersion([string] $ExpectedVersion) { + if ($ExpectedVersion -notmatch '^\d+\.\d+\.\d+$') { Refuse 'expected distribution version is invalid' } + # A single-quoted here-string is literal PowerShell text. The previous calls + # used backslash-escaped double quotes (shell syntax, not PowerShell syntax), + # which ended the PowerShell string and executed `systemctl` on Windows. + # Substitute only the already-validated semver after constructing the exact + # bash command, so every service/version assertion really runs inside WSL. + $command = @' +test "$(systemctl is-active 1helm.service)" = active +test "$(node -p 'require("/opt/1helm/current/package.json").version')" = '__EXPECTED__' +'@ + Invoke-Distro ($command.Replace('__EXPECTED__', $ExpectedVersion)) +} if ($env:GITHUB_REPOSITORY -ne 'gitcommit90/1Helm' -or $env:GITHUB_EVENT_NAME -ne 'workflow_run' -or $env:GITHUB_REF -ne 'refs/heads/main' -or $env:GITHUB_SHA -ne $Commit -or @@ -97,7 +110,7 @@ if ($LASTEXITCODE -ne 0) { Refuse "exact candidate clean install failed with exi if (-not ((Get-Distros) -ccontains $Distro) -or -not ((Get-Distros) -ccontains $Unrelated)) { Refuse 'clean install did not retain both target and unrelated control distributions' } $cleanHealth = Invoke-WebRequest -Uri 'http://localhost:8123/api/setup/status' -UseBasicParsing -TimeoutSec 10 if ($cleanHealth.StatusCode -ne 200 -or -not (($cleanHealth.Content | ConvertFrom-Json).needs_setup)) { Refuse 'candidate clean install did not expose localhost onboarding health' } -Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$Version'" +Assert-DistroVersion $Version & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $UninstallScript -Distro $Distro -InstallRoot $InstallRoot -Force if ($LASTEXITCODE -ne 0 -or (Get-Distros) -ccontains $Distro -or -not ((Get-Distros) -ccontains $Unrelated) -or (Test-Path $InstallRoot)) { Refuse 'clean-install teardown was not scoped to the exact target' @@ -133,7 +146,7 @@ if (-not ((Get-Distros) -ccontains $Unrelated)) { Refuse 'unrelated WSL control $health = Invoke-WebRequest -Uri 'http://localhost:8123/api/setup/status' -UseBasicParsing -TimeoutSec 10 if ($health.StatusCode -ne 200 -or -not (($health.Content | ConvertFrom-Json).needs_setup)) { Refuse 'prior Stable localhost health did not report a clean install' } -Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$PreviousVersion'" +Assert-DistroVersion $PreviousVersion # Stage the exact candidate as a verified retained release, then apply its real # atomic Linux update transaction from prior Stable to candidate. @@ -145,7 +158,7 @@ Copy-Item $Archive (Join-Path $Stage 'candidate.tgz') -Force $StageInDistro = '/mnt/' + $Stage.Substring(0,1).ToLowerInvariant() + ($Stage.Substring(2) -replace '\\','/') $CandidateRelease = "/opt/1helm/releases/$Version-$Digest" Invoke-Distro "set -e; rm -rf '$CandidateRelease.tmp'; mkdir -p '$CandidateRelease.tmp'; tar -xzf '$StageInDistro/candidate.tgz' -C '$CandidateRelease.tmp' --strip-components=1; chown -R 1helm:1helm '$CandidateRelease.tmp'; mv '$CandidateRelease.tmp' '$CandidateRelease'; '$CandidateRelease/site/public/apply-linux-release.sh' '$CandidateRelease' '$Version'" -Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$Version'" +Assert-DistroVersion $Version $StateAfterUpdate = (& $Wsl -d $Distro -u root --exec sha256sum /var/lib/1helm-oci-v1/phase4-acceptance-state | Out-String).Split()[0] if ($StateBefore -ne $StateAfterUpdate) { Refuse 'WSL data marker changed across update' } @@ -178,7 +191,7 @@ for ($i = 0; $i -lt 90; $i++) { Start-Sleep -Seconds 2 } if (-not $Recovered) { Refuse 'snapshot-assisted WSL cold-start equivalent did not recover localhost health' } -Invoke-Distro "test \"`$(systemctl is-active 1helm.service)\" = active; test \"`$(node -p 'require(\"/opt/1helm/current/package.json\").version')\" = '$Version'" +Assert-DistroVersion $Version $StateAfter = (& $Wsl -d $Distro -u root --exec sha256sum /var/lib/1helm-oci-v1/phase4-acceptance-state | Out-String).Split()[0] if ($StateBefore -ne $StateAfter) { Refuse 'WSL data marker changed across update or cold-start equivalent' } diff --git a/test/phase4-platform-acceptance.mjs b/test/phase4-platform-acceptance.mjs index 15a2f0f..ee1c0bb 100644 --- a/test/phase4-platform-acceptance.mjs +++ b/test/phase4-platform-acceptance.mjs @@ -122,6 +122,10 @@ test("workflow routes no PR/fork code, uses unique labels, fans acceptance out, assert.match(linuxAccept, /RUNNER_ENVIRONMENT.*==.*"github-hosted"/); assert.match(linuxAccept, /1helm-standalone/); assert.match(linuxAccept, /refuses to run while port 8123 is already in use/); + const macAccept = read("ops/platform-acceptance/macos.sh"); + assert.match(macAccept, /wait_for_setup_health/); + assert.match(macAccept, /lsof[\s\S]*awk[\s\S]*\|\| true/); + assert.doesNotMatch(macAccept, /print a\[length\(a\)\]; exit/); assert.match(workflow.match(/accept-macos:[\s\S]*?(?=\n accept-windows:)/)?.[0] || "", /if: always\(\)[\s\S]*name: 1helm-macos-acceptance-/); assert.match(workflow.match(/accept-windows:[\s\S]*?(?=\n assemble-promotion:)/)?.[0] || "", /if: always\(\)[\s\S]*name: 1helm-windows-acceptance-/); }); @@ -206,6 +210,9 @@ test("Windows code publishes no artifact/signing claim and requires honest reboo assert.match(windows, /snapshot_baseline/); assert.doesNotMatch(windows, /reboot-evidence\.json/); assert.match(windows, /apply-linux-release\.sh/); + assert.match(windows, /function Assert-DistroVersion/); + assert.equal((windows.match(/Assert-DistroVersion \$(?:Version|PreviousVersion)/g) || []).length, 4); + assert.doesNotMatch(windows, /Invoke-Distro "test .*systemctl/); assert.match(windows, /LocalRootfs/); assert.match(read("site/public/install.ps1"), /LocalRootfsSha256/); assert.match(windows, /no distinct prior Stable release/); From 80a679599813b15aa1af916f8f20c2b5e55e89e4 Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Tue, 4 Aug 2026 20:28:13 +0000 Subject: [PATCH 2/2] fix(phase4): track Windows acceptance host provisioning and repair bootstrap The Windows acceptance host bootstrap existed only as a pre-built ISO on the hypervisor, so it could not be reviewed, reproduced, or corrected. Track it and fix two defects found while provisioning a fresh host. setup.ps1 installed the VirtIO network driver but never the VirtIO serial driver. The QEMU guest agent then starts and reports Running while the host side (qm agent ping) stays dead, leaving no guest-exec channel to provision through. setup.ps1 also skipped firewall setup whenever an OpenSSH-Server-In-TCP rule already existed. Add-WindowsCapability creates that rule scoped to the Private profile only, and a freshly bridged VM is categorized Public, so port 22 stayed unreachable even though sshd reported Running. The operator public key is no longer embedded. This repository is public, so publishing which key is authorized as Administrator on the acceptance host is needless disclosure; it is supplied at build time and staged onto the media. Also 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 whole VM, which the acceptance lane depends on for restoring its accepted clean baseline; and Windows 11 25H2 no longer auto-applies an answer file from secondary media. No release, tag, version bump, website deploy, or production change. Co-Authored-By: Claude --- docs/phase4-platform-acceptance.md | 15 ++++ .../windows-host/.gitignore | 2 + .../windows-host/Autounattend.xml | 50 +++++++++++++ .../windows-host/README.md | 75 +++++++++++++++++++ .../windows-host/build-unattend-iso.sh | 36 +++++++++ .../windows-host/setup.ps1 | 62 +++++++++++++++ 6 files changed, 240 insertions(+) create mode 100644 ops/platform-acceptance/windows-host/.gitignore create mode 100644 ops/platform-acceptance/windows-host/Autounattend.xml create mode 100644 ops/platform-acceptance/windows-host/README.md create mode 100755 ops/platform-acceptance/windows-host/build-unattend-iso.sh create mode 100644 ops/platform-acceptance/windows-host/setup.ps1 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 +}