From 7d6ae42fcfce4ceecea8a894e79515971aeb040a Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 10 May 2026 11:30:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(template):=20=C2=A77.4=20e2e=20fixes=20+=20?= =?UTF-8?q?autologin=20+=20validation=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix emerged during §7.4 e2e test run (2026-05-10): - Deploy: set UsoSvc to Manual explicitly (Server 2025 default = Automatic) - Setup cleanup: re-apply Set-Service Disabled on wuauserv/UsoSvc after DISM StartComponentCleanup (WaaSMedicSvc resets StartType during component store cleanup) - Deploy + Setup: add Administrator autologin (AutoAdminLogon, DefaultUserName, DefaultPassword, DefaultDomainName) in post-install.ps1; Assert-Step 5c validates it - Add Validate-DeployState.ps1: standalone host-side check of all Deploy-set state - Add Validate-SetupState.ps1: standalone host-side check of full post-Setup state - Mark §7.4, §7.1 and §7.2 e2e validation items as complete in TODO.md - Update docs: WINDOWS-TEMPLATE-SETUP.md (arch table, validation scripts section, autologin in confine Deploy/Setup); TEST-7.4-e2e.md checklist marked done Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 20 +- docs/TEST-7.4-e2e.md | 18 +- docs/WINDOWS-TEMPLATE-SETUP.md | 37 +++- template/Deploy-WinBuild2025.ps1 | 309 ++++++++++++++++++++++-------- template/Prepare-WinBuild2025.ps1 | 27 ++- template/Setup-WinBuild2025.ps1 | 12 +- template/Validate-DeployState.ps1 | 186 ++++++++++++++++++ template/Validate-SetupState.ps1 | 259 +++++++++++++++++++++++++ 8 files changed, 762 insertions(+), 106 deletions(-) create mode 100644 template/Validate-DeployState.ps1 create mode 100644 template/Validate-SetupState.ps1 diff --git a/TODO.md b/TODO.md index 21a1498..91989f9 100644 --- a/TODO.md +++ b/TODO.md @@ -632,7 +632,7 @@ File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1), - [x] Setup Step 4b (UAC) → Assert-Step only - [x] Setup Step 5b (Explorer LaunchTo) → Assert-Step only - [x] Setup Step 5c (Server Manager / DisableCAD / OOBE) → Assert-Step only -- [ ] E2e validation — vedi §7.4 +- [x] E2e validation — completata §7.4 (2026-05-10) ### 7.2 [P1] WU lifecycle — strategia B (Deploy: GPO + Manual; Setup: lifecycle) File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1), @@ -647,7 +647,7 @@ avvia WU via SchTask SYSTEM, poi Step 6b disabilita servizi permanentemente post - [x] Setup Step 6 Step A: già rimuove `DisableWindowsUpdateAccess` + `NoAutoUpdate` prima del COM API — nessuna modifica necessaria - [x] Setup Step 6 Step D: già fa `Set-Service start=Manual` + `Start-Service` — idempotente con servizi già Manual - [x] Setup Step 6b: già disabilita wuauserv/UsoSvc + GPO keys — nessuna modifica necessaria -- [ ] E2e validation — vedi §7.4 +- [x] E2e validation — completata §7.4 (2026-05-10) ### 7.3 [P2] Update header docs di entrambi gli script @@ -655,12 +655,18 @@ avvia WU via SchTask SYSTEM, poi Step 6b disabilita servizi permanentemente post - [x] [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1) `.DESCRIPTION`: Steps 1/3/4b/5b/5c marcati "validation-only — set by Deploy"; step ordering rationale aggiornato - [x] [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md): architettura "tre script", VMX path `CI-WinBuild.vmx`, Fase C step list + razionale + tabella validazioni aggiornati -### 7.4 [P2] Test e2e refactor +### 7.4 [P2] Test e2e refactor — COMPLETATO 2026-05-10 -- [ ] Deploy stand-alone su VM test → `Get-NetFirewallProfile` tutti `Enabled=False`, `Get-Service wuauserv | Select StartType` → `Manual`, `NoAutoUpdate=1` GPO presente -- [ ] Prepare dopo Deploy con `-SkipWindowsUpdate` → tutti `Assert-Step` passano `[OK]` senza eseguire Set (log mostra solo validation output) -- [ ] Prepare senza `-SkipWindowsUpdate` → WU gira via SchTask SYSTEM, exit code 0/2/3, Step 6b disabilita wuauserv/UsoSvc (`StartType=Disabled`) -- [ ] Final gate del guest passa su tutti e 7 i check +- [x] Deploy stand-alone su VM test → `Get-NetFirewallProfile` tutti `Enabled=False`, `Get-Service wuauserv | Select StartType` → `Manual`, `NoAutoUpdate=1` GPO presente +- [x] Prepare dopo Deploy con `-SkipWindowsUpdate` → tutti `Assert-Step` passano `[OK]` senza eseguire Set (log mostra solo validation output) +- [x] Prepare senza `-SkipWindowsUpdate` → WU (0 update trovati, ResultCode=0), Step 6b disabilita wuauserv/UsoSvc (`StartType=Disabled`) +- [x] Final gate del guest passa su tutti e 7 i check; snapshot `BaseClean` preso + +**Fix emersi durante §7.4** (applicati): +- Deploy: `UsoSvc` impostato a `Manual` esplicitamente (default Server 2025 = Automatic) +- Setup cleanup: `Set-Service -StartupType Disabled` ri-applicato su wuauserv/UsoSvc dopo DISM `StartComponentCleanup` (DISM/WaaSMedicSvc può resettare StartType) +- Deploy + Setup: autologin Administrator (`AutoAdminLogon=1`) aggiunto in post-install.ps1 e validato in Assert-Step 5c +- Nuovi script: `template/Validate-DeployState.ps1`, `template/Validate-SetupState.ps1` ### 7.5 [P3] Rinomina Setup → Setup-CITools (opzionale, futuro) Una volta che Setup fa solo build customization, nome `Setup-WinBuild2025` è impreciso — diff --git a/docs/TEST-7.4-e2e.md b/docs/TEST-7.4-e2e.md index 8f0f819..423f111 100644 --- a/docs/TEST-7.4-e2e.md +++ b/docs/TEST-7.4-e2e.md @@ -153,9 +153,17 @@ Per promuovere a produzione (rimpiazza template esistente): --- -## Checklist §7.4 +## Checklist §7.4 — COMPLETATA 2026-05-10 -- [ ] Step 1: Deploy exit 0, snapshot `PostInstall` presente -- [ ] Step 2: Firewall=False, wuauserv=Manual, NoAutoUpdate=1, MaxMemory≥2048, UAC=0 -- [ ] Step 3: Prepare `-SkipWindowsUpdate` exit 0, nessun Set in Step 1/3/4b/5b/5c -- [ ] Step 4: Prepare senza `-Skip` exit 0, wuauserv=Disabled post-Step 6b +- [x] Step 1: Deploy exit 0, snapshot `PostInstall` presente +- [x] Step 2: Firewall=False, wuauserv=Manual, NoAutoUpdate=1, MaxMemory≥2048, UAC=0 + (UsoSvc era Automatic — fix applicato in Deploy; corretto manualmente sulla VM test) +- [x] Step 3: Prepare `-SkipWindowsUpdate` exit 0, nessun Set in Step 1/3/4b/5b/5c +- [x] Step 4: Prepare senza `-Skip` exit 0 (WU: 0 update, ResultCode=0), wuauserv=Disabled post-Step 6b +- [x] Snapshot `BaseClean` preso con successo + +**Fix applicati durante il test**: +- Deploy: `UsoSvc` esplicitamente a `Manual` (era omesso, default Server 2025 = Automatic) +- Setup cleanup: ri-applica `Disabled` su wuauserv/UsoSvc dopo DISM (WaaSMedicSvc resets StartType) +- Deploy + Setup 5c: autologin Administrator (`AutoAdminLogon=1`, `DefaultUserName`, `DefaultPassword`, `DefaultDomainName`) +- Nuovi: `Validate-DeployState.ps1`, `Validate-SetupState.ps1` diff --git a/docs/WINDOWS-TEMPLATE-SETUP.md b/docs/WINDOWS-TEMPLATE-SETUP.md index 492d0b1..5408d14 100644 --- a/docs/WINDOWS-TEMPLATE-SETUP.md +++ b/docs/WINDOWS-TEMPLATE-SETUP.md @@ -16,9 +16,11 @@ credenziali archiviate, ISO Windows Server presente). | `template/Deploy-WinBuild2025.ps1` | **Host** | Opzionale: crea VM e installa Windows unattended da ISO | | `template/Prepare-WinBuild2025.ps1` | **Host** | Orchestratore: copia + esegue Setup nel guest via WinRM | | `template/Setup-WinBuild2025.ps1` | **Dentro VM** | CI toolchain (user, .NET, Python, VS) + validazione hardening Deploy | +| `template/Validate-DeployState.ps1` | **Host** | Valida stato post-Deploy prima di eseguire Prepare | +| `template/Validate-SetupState.ps1` | **Host** | Valida stato post-Setup (Deploy + Setup) prima dello snapshot | **Confine Deploy / Setup** (refactor §7, 2026-05-10): -- `Deploy-WinBuild2025.ps1` si occupa dell'**OS hardening** (Firewall, WinRM, UAC, Defender, UX tweaks, WU GPO locks). Se usato, Setup valida questi come Assert-Step senza re-applicarli. +- `Deploy-WinBuild2025.ps1` si occupa dell'**OS hardening** (Firewall, WinRM, UAC, Defender, UX tweaks, autologin Administrator, WU GPO locks). Se usato, Setup valida questi come Assert-Step senza re-applicarli. - `Setup-WinBuild2025.ps1` si occupa della **CI customization** (utente `ci_build`, cartelle `C:\CI`, toolchain .NET/Python/VS, Windows Update lifecycle, cleanup, final gate). `Prepare-WinBuild2025.ps1` apre una WinRM session, copia `Setup-WinBuild2025.ps1` in @@ -161,7 +163,7 @@ Step 4b UAC: validation only — Deploy set EnableLUA=0 + TokenFilterPolicy Step 5 CI dirs: C:\CI\{build, output, scripts} ── C:\CI deve esistere prima di WU Step 5b Explorer LaunchTo=1: validation only — Deploy set HKCU + Default hive [Assert-Step] Step 5c CI UX hardening: validation only — Deploy set lock screen, SM policy/task, - DisableCAD, OOBE, telemetry [Assert-Step] + DisableCAD, OOBE, telemetry, autologin Administrator [Assert-Step] Step 6 Windows Update via Scheduled Task SYSTEM ── clears Deploy GPO locks, runs WU Step 6b Windows Update permanently disabled (post-update lockdown) Step 7 .NET SDK install (channel via dotnet-install.ps1) @@ -196,7 +198,7 @@ Final Pre-snapshot gate — 7 check cross-cutting + no installer leftover | 4b | Assert | EnableLUA=0, LocalAccountTokenFilterPolicy=1 | | 5 | Operativo | Ogni dir Test-Path -PathType Container | | 5b | Assert | HKCU LaunchTo=1 | -| 5c | Assert | NoLockScreen=1, SM policy DoNotOpenAtLogon=1, SM task Disabled, DisableCAD=1, OOBE DisablePrivacyExperience=1, AllowTelemetry=0 | +| 5c | Assert | NoLockScreen=1, SM policy DoNotOpenAtLogon=1, SM task Disabled, DisableCAD=1, OOBE DisablePrivacyExperience=1, AllowTelemetry=0, AutoAdminLogon=1 + DefaultUserName=Administrator | | 6 | Operativo | ResultCode ∈ {0,2,3}, no reboot required (else exit 3010) | | 6b | Operativo | wuauserv StartType=Disabled, NoAutoUpdate=1, DisableWindowsUpdateAccess=1 | | 7 | Operativo | `dotnet --version` channel match, machine PATH contiene `C:\dotnet` | @@ -220,6 +222,35 @@ Final Pre-snapshot gate — 7 check cross-cutting + no installer leftover --- +## Validation scripts standalone + +Due script per validare il guest in punti specifici del flusso, indipendentemente da +Prepare. Utili per debug, re-verifica dopo modifiche manuali, e CI automatizzata. + +### Dopo Deploy — prima di Prepare + +```powershell +.\template\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129 +``` + +Controlla: Firewall off, Defender GPO, WinRM (Running/Automatic/AllowUnencrypted/Basic/MaxMem), +UAC off, Explorer LaunchTo, NoLockScreen, Server Manager (policy+HKLM+task+HKCU), +DisableCAD, OOBE, AllowTelemetry, **AutoAdminLogon=1**, WU GPO, wuauserv/UsoSvc=Manual. + +### Dopo Prepare — prima di snapshot + +```powershell +.\template\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129 +``` + +Tutti i check Deploy + ci_build (exists/enabled/admin/PasswordNeverExpires), +`C:\CI\{build,output,scripts}`, wuauserv/UsoSvc=Disabled, WU GPO, +.NET SDK channel match, Python version match, MSBuild present, no leftover installer files. + +Exit 0 = tutto OK. Exit 1 = almeno un check fallito (output indica quale). + +--- + ## Fase E — Verifica finale Dall'host: diff --git a/template/Deploy-WinBuild2025.ps1 b/template/Deploy-WinBuild2025.ps1 index 2207b17..02e124c 100644 --- a/template/Deploy-WinBuild2025.ps1 +++ b/template/Deploy-WinBuild2025.ps1 @@ -14,11 +14,11 @@ via IMAPI2FS COM (built-in Windows, zero deps) Step 4 — Create the VMDK (80 GB single-file thin) via vmware-vdiskmanager Step 5 — Generate the .vmx (UEFI no SecureBoot, NVMe disk, e1000e NIC, - three CD-ROMs: Win install + autounattend + VMware Tools) - Step 6 — Power on the VM (vmrun start nogui) + two CD-ROMs: Win install + autounattend; Tools bundled inside autounattend) + Step 6 — Power on the VM (vmrun start gui/nogui — async to avoid Workstation hang) Step 7 — Wait for the install_complete.flag file inside the guest (created by post-install.ps1 once Tools install finishes) - Step 8 — Graceful soft-stop, swap NIC e1000e → vmxnet3 in the .vmx + Step 8 — Graceful soft-stop, remove CD-ROM controller + USB from VMX, swap NIC e1000e → vmxnet3 Step 9 — Power on, wait for guest IP via vmrun (vmxnet3 verified) Step 10 — Take snapshot named $SnapshotName Final — Print summary (VM path, IP, snapshot name) @@ -49,6 +49,9 @@ - Lock screen disabled, Server Manager (policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System + Winlogon), OOBE/telemetry suppressed; Setup-WinBuild2025 Step 5b/5c validate all of these + - Taskbar search bar hidden, Task View button hidden + - Windows Terminal set as default terminal (DelegationConsole/Terminal HKCU + Default hive) + - Azure Arc auto-start, scheduled tasks, and himds service disabled - VMware Tools installed (Complete) from Tools ISO - install_complete.flag dropped in C:\Windows\Temp @@ -56,7 +59,8 @@ Path to the Windows Server 2025 install ISO. .PARAMETER ToolsISO - Path to the VMware Tools ISO (windows.iso). + Path to the VMware Tools ISO (windows.iso). Contents are extracted and bundled + inside the autounattend ISO at build time (Step 3); not mounted as a separate CD-ROM. .PARAMETER VMXPath Full path of the .vmx file to create. The folder is created if missing. @@ -144,7 +148,7 @@ param( # ── Hardware ── [int] $DiskSizeGB = 80, [int] $MemoryMB = 6144, - [int] $VCPUCount = 2, + [int] $VCPUCount = 4, [int] $CoresPerSocket = 2, # ── Locale ── @@ -165,7 +169,14 @@ param( # When set, vmrun powers on the VM with the Workstation GUI visible. # Default: headless (nogui) for unattended pipelines. - [switch] $ShowGui + [switch] $ShowGui, + + # Skip straight to this step number. All derived paths are still computed; + # artifacts from skipped steps must already exist on disk. + # Steps: 1=preflight 2=render XML 3=autounattend ISO 4=VMDK+noPromptISO + # 5=VMX 6=power on 7=wait flag 8=stop+NIC 9=power on vmxnet3 10=snapshot + [ValidateRange(1,10)] + [int] $StartFromStep = 1 ) $vmrunUiMode = if ($ShowGui) { 'gui' } else { 'nogui' } @@ -178,8 +189,13 @@ $ErrorActionPreference = 'Stop' # ───────────────────────────────────────────────────────────────────────────── function Write-Step { - param([string]$Message) - Write-Host "`n=== $Message ===" -ForegroundColor Cyan + param([string] $Message, [int] $Step = 0) + $skipped = $Step -gt 0 -and $script:StartFromStep -gt $Step + if ($skipped) { + Write-Host "`n=== $Message === [SKIP]" -ForegroundColor DarkGray + } else { + Write-Host "`n=== $Message ===" -ForegroundColor Cyan + } } function Assert-Step { @@ -350,8 +366,14 @@ function New-WinIsoNoPrompt { function Invoke-Vmrun { param([Parameter(Mandatory)] [string[]] $Arguments, - [switch] $IgnoreErrors) + [switch] $IgnoreErrors, + [switch] $Async) $vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe' + if ($Async) { + # vmrun start can block indefinitely on some Workstation versions; fire async. + Start-Process -FilePath $vmrun -ArgumentList $Arguments -NoNewWindow + return + } $stdout = & $vmrun @Arguments 2>&1 $code = $LASTEXITCODE if ($code -ne 0 -and -not $IgnoreErrors) { @@ -377,15 +399,80 @@ function Set-VmxKey { Set-Content -LiteralPath $VmxPath -Value $newLines -Encoding ASCII } +function Remove-VmxLines { + param([Parameter(Mandatory)] [string] $VmxPath, + [Parameter(Mandatory)] [string] $KeyPrefix) + $pat = "^\s*$([regex]::Escape($KeyPrefix))" + $lines = Get-Content -LiteralPath $VmxPath | Where-Object { $_ -notmatch $pat } + Set-Content -LiteralPath $VmxPath -Value $lines -Encoding ASCII +} + +function Test-VmInList { + param([Parameter(Mandatory)] [string] $VmxPath) + $list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors + $forward = $VmxPath -replace '\\', '/' + $back = $VmxPath -replace '/', '\' + return ($list -match [regex]::Escape($forward)) -or ($list -match [regex]::Escape($back)) +} + +function Invoke-VmrunBounded { + # Run vmrun synchronously but kill the vmrun process after $TimeoutSeconds. + # Useful for 'stop soft': vmrun blocks until guest shuts down, but the ACPI + # signal is sent within seconds — killing vmrun doesn't cancel the guest shutdown. + param([Parameter(Mandatory)] [string[]] $Arguments, + [int] $TimeoutSeconds = 30) + $exe = Join-Path $VMwareWorkstationDir 'vmrun.exe' + $proc = Start-Process -FilePath $exe -ArgumentList $Arguments -NoNewWindow -PassThru + $null = $proc.WaitForExit($TimeoutSeconds * 1000) + if (-not $proc.HasExited) { $proc.Kill() } +} + +function Stop-Vm { + param([Parameter(Mandatory)] [string] $VmxPath, + [int] $SoftTimeoutMinutes = 5) + # Send soft-stop (ACPI); vmrun may block until guest finishes — kill vmrun after 30 s. + # Guest OS continues shutdown independently once the signal is received. + Invoke-VmrunBounded -Arguments @('-T','ws','stop',$VmxPath,'soft') -TimeoutSeconds 30 + $deadline = (Get-Date).AddMinutes($SoftTimeoutMinutes) + do { + Start-Sleep -Seconds 5 + } while ((Get-Date) -lt $deadline -and (Test-VmInList -VmxPath $VmxPath)) + if (Test-VmInList -VmxPath $VmxPath) { + Write-Host ' soft stop timed out — hard stop' + Invoke-VmrunBounded -Arguments @('-T','ws','stop',$VmxPath,'hard') -TimeoutSeconds 15 + Start-Sleep -Seconds 5 + } +} + # ───────────────────────────────────────────────────────────────────────────── # Step 1: Pre-flight validation # ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 1: pre-flight validation' - +# ── Derived paths — always computed regardless of -StartFromStep ───────────── $vmrunPath = Join-Path $VMwareWorkstationDir 'vmrun.exe' $vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe' $scriptRoot = Split-Path -Parent $PSCommandPath $xmlTemplate = Join-Path $scriptRoot 'autounattend.template.xml' +$vmDir = Split-Path -Parent $VMXPath +$vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk' +$vmdkPath = Join-Path $vmDir $vmdkName +$autounattendIso = Join-Path $vmDir 'autounattend.iso' +$stagingDir = Join-Path $vmDir '_autounattend_staging' +$guestOS = 'windows2019srvnext-64' +$guestIP = $null # set in Step 9; pre-init for strict-mode safety +if ([string]::IsNullOrWhiteSpace($WinISONoPromptPath)) { + $srcDir = Split-Path -Parent $WinISO + $srcBase = [IO.Path]::GetFileNameWithoutExtension($WinISO) + $WinISONoPromptPath = Join-Path $srcDir "$srcBase.patched.iso" +} +if ($StartFromStep -gt 1) { + Write-Host " [SKIP] Steps 1..$($StartFromStep - 1) — starting from Step $StartFromStep" -ForegroundColor Yellow +} + +# ───────────────────────────────────────────────────────────────────────────── +# Step 1: pre-flight validation +# ───────────────────────────────────────────────────────────────────────────── +Write-Step 'Step 1: pre-flight validation' -Step 1 +if ($StartFromStep -le 1) { Assert-Step 'PreFlight' "vmrun.exe found at $vmrunPath" { Test-Path $vmrunPath } Assert-Step 'PreFlight' "vmware-vdiskmanager.exe found at $vdiskMgr" { Test-Path $vdiskMgr } @@ -395,26 +482,21 @@ Assert-Step 'PreFlight' "autounattend template exists: $xmlTemplate" { Test-Pat Assert-Step 'PreFlight' "ComputerName valid (1-15 chars, no spaces)" { $ComputerName -match '^[A-Za-z0-9-]{1,15}$' } - -$vmDir = Split-Path -Parent $VMXPath if (-not (Test-Path $vmDir)) { New-Item -ItemType Directory -Path $vmDir -Force | Out-Null } Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir } - if (Test-Path $VMXPath) { throw "[PreFlight] $VMXPath already exists. Remove the existing VM folder before re-running." } -$vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk' -$vmdkPath = Join-Path $vmDir $vmdkName -$autounattendIso = Join-Path $vmDir 'autounattend.iso' -$stagingDir = Join-Path $vmDir '_autounattend_staging' +} # end Step 1 # ───────────────────────────────────────────────────────────────────────────── # Step 2: render autounattend.xml + post-install.ps1 # ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 2: render autounattend.xml + post-install.ps1' +Write-Step 'Step 2: render autounattend.xml + post-install.ps1' -Step 2 +if ($StartFromStep -le 2) { if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force } New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null @@ -513,14 +595,15 @@ sc.exe stop DiagTrack | Out-Null L 'Telemetry disabled' # ── Windows Update GPO locks — services left Manual (§7.2 Strategy B) ─── -# Services remain at Windows default (Manual/on-demand). Setup-WinBuild2025 -# Step 6 invokes the WU COM API via a SYSTEM scheduled task; it clears these -# GPO keys first (Step A), applies updates, then permanently disables services -# in Step 6b (post-update lockdown). These keys block background auto-start -# between Deploy and Setup without breaking the on-demand COM API. +# Set both WU services to Manual so they can be invoked on-demand by Setup +# Step 6 (WU COM API via SYSTEM task) but won't auto-start between Deploy and Setup. +# UsoSvc defaults to Automatic on Windows Server 2025; must be explicit here. +# GPO keys below also block background triggers. Step 6b permanently disables both. +Set-Service -Name wuauserv -StartupType Manual -ErrorAction SilentlyContinue +Set-Service -Name UsoSvc -StartupType Manual -ErrorAction SilentlyContinue reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' /v DisableWindowsUpdateAccess /t REG_DWORD /d 1 /f | Out-Null reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null -L 'Windows Update GPO locks set (services remain Manual; Setup Step 6b permanently disables)' +L 'Windows Update GPO locks set + wuauserv/UsoSvc Manual (Setup Step 6b permanently disables)' # ── Firewall: all profiles disabled + allow RDP + ICMP + WinRM HTTPS ───── # Disabling all profiles removes any block on subsequent WinRM config and WU @@ -576,9 +659,15 @@ reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManage schtasks /Change /TN '\Microsoft\Windows\Server Manager\ServerManager' /Disable 2>&1 | Out-Null # DisableCAD in Winlogon (belt-and-suspenders with Policies\System key set above) reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null +# Autologin — Administrator auto-signs in after every boot (CI template convenience). +# Password stored plaintext in registry; acceptable for isolated VMnet8 NAT lab VM. +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v AutoAdminLogon /t REG_SZ /d 1 /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultUserName /t REG_SZ /d Administrator /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultPassword /t REG_SZ /d "$AdminPassword" /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultDomainName /t REG_SZ /d '.' /f | Out-Null # OOBE non-policy key (supplements the Policies\Windows\OOBE key set above) reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null -L 'Lock screen, SM GPO policy + task, CAD Winlogon, OOBE non-policy key set' +L 'Lock screen, SM GPO policy + task, CAD Winlogon, autologin Administrator, OOBE non-policy key set' # ── IE Enhanced Security Configuration off (Admin) ─────────────────────── # Long-known CI papercut: ESC blocks every URL in admin sessions. @@ -587,6 +676,13 @@ reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37E Stop-Process -Name iexplore -ErrorAction SilentlyContinue L 'IE ESC disabled' +# ── Remove drive letter from EFI System Partition ──────────────────────── +# Windows setup sometimes assigns 'S:' to the ESP making it visible in Explorer. +Get-Partition | Where-Object { `$_.DriveLetter -eq 'S' } | ForEach-Object { + `$_ | Remove-PartitionAccessPath -AccessPath 'S:\' -ErrorAction SilentlyContinue +} +L 'EFI partition drive letter removed (if any)' + # ── Hibernate off (frees ~RAM-size on C:, no need on VMs) ──────────────── powercfg /h off 2>&1 | Out-Null # Power plan High Performance — never sleep, never throttle CPU @@ -621,11 +717,51 @@ foreach (`$task in @( } L 'CEIP / feedback scheduled tasks disabled' +# ── Azure Arc auto-start / tray icon off ──────────────────────────────── +# Server 2025 ships with Azure Arc integration enabled; suppress for CI VMs. +foreach (`$arcTask in @( + '\Microsoft\Azure Arc\Onboarding\EnableAzureArcSetup', + '\Microsoft\Azure Arc\Onboarding\ArcScan', + '\Microsoft\AzureArcSeamlessOnboarding\ArcSetup' +)) { + schtasks /Change /TN `$arcTask /Disable 2>&1 | Out-Null +} +foreach (`$arcSvc in 'himds','ArcProxyAgent') { + `$s = Get-Service -Name `$arcSvc -ErrorAction SilentlyContinue + if (`$s) { + sc.exe config `$arcSvc start= disabled | Out-Null + sc.exe stop `$arcSvc 2>&1 | Out-Null + } +} +reg add 'HKLM\SOFTWARE\Microsoft\AzureArc' /v OnboardingState /t REG_DWORD /d 4 /f 2>&1 | Out-Null +reg delete 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' /v AzureArcSetup /f 2>&1 | Out-Null +L 'Azure Arc auto-start disabled' + # ── Explorer UX: show file extensions + open to This PC ────────────────── reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v HideFileExt /t REG_DWORD /d 0 /f | Out-Null reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v LaunchTo /t REG_DWORD /d 1 /f | Out-Null L 'Explorer UX tweaks applied (HKCU)' +# ── Taskbar / terminal UX tweaks ───────────────────────────────────────── +# SearchboxTaskbarMode=0 hide search bar +# ShowTaskViewButton=0 hide Task View button +# DelegationConsole/Terminal Windows Terminal as default terminal +# LayoutModification.xml pre-pin Windows Terminal for new user profiles + +# HKCU (SYSTEM context at FirstLogon) +reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKCU\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +reg add 'HKCU\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +# Default hive — Administrator + any future user inherits on first interactive logon +reg load HKU\DefaultUser2 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null +reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +reg unload HKU\DefaultUser2 2>&1 | Out-Null +L 'Taskbar: search hidden, task view hidden, WT default terminal' + # ── Wait for VMware Tools install to settle (kicked off at top) ────────── # Wrapper returned immediately while msiexec runs async. Poll until: # (a) no setup/setup64/vminst wrapper process (excludes system msiexec /V) @@ -664,9 +800,11 @@ if (`$toolsExe) { New-Item -ItemType File -Path 'C:\Windows\Temp\install_complete.flag' -Force | Out-Null L 'install_complete.flag dropped' -# ── Reboot to apply Tools/UAC/services state cleanly ────────────────────── -L 'rebooting in 15s' -shutdown /r /t 15 /f /c "post-install complete" +# ── Shut down (not restart) so host Step 8 can swap NIC and snapshot ────── +# Delay 60s: host polls every 30s, needs one cycle after flag appears. +# Step 8 sends vmrun stop soft on top of this — Windows handles both. +L 'shutting down in 60s' +shutdown /s /t 60 /f /c "post-install complete" "@ $psOut = Join-Path $stagingDir 'post-install.ps1' Set-Content -LiteralPath $psOut -Value $postInstall -Encoding UTF8 @@ -690,10 +828,13 @@ Assert-Step 'Render' 'Tools setup bundled in staging\Tools' { (Test-Path (Join-Path $toolsStage 'setup64.exe')) } +} # end Step 2 + # ───────────────────────────────────────────────────────────────────────────── # Step 3: build autounattend ISO # ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 3: build autounattend ISO via IMAPI2FS' +Write-Step 'Step 3: build autounattend ISO via IMAPI2FS' -Step 3 +if ($StartFromStep -le 3) { New-IsoFromFolder -SourceFolder $stagingDir -IsoPath $autounattendIso -VolumeLabel 'AUTOUNATTEND' Assert-Step 'IsoBuild' "autounattend.iso created at $autounattendIso" { Test-Path $autounattendIso } @@ -708,10 +849,13 @@ if (Test-Path $stagingDir) { Write-Host " [warn] staging dir not fully cleaned, leaving in place: $stagingDir" -ForegroundColor Yellow } +} # end Step 3 + # ───────────────────────────────────────────────────────────────────────────── -# Step 4: create VMDK +# Step 4: create VMDK + no-prompt Windows ISO # ───────────────────────────────────────────────────────────────────────────── -Write-Step "Step 4: create $DiskSizeGB GB VMDK (single-file growable)" +Write-Step "Step 4: create $DiskSizeGB GB VMDK (single-file growable)" -Step 4 +if ($StartFromStep -le 4) { # -t 0 = single growable file (thin); -a lsilogic affects descriptor only, # the disk attaches to the NVMe controller defined in the VMX. @@ -723,13 +867,7 @@ Assert-Step 'Vmdk' "VMDK file present: $vmdkPath" { Test-Path $vmdkPath } # ───────────────────────────────────────────────────────────────────────────── # Step 4b: rebuild Windows ISO with EFI no-prompt boot image # ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")' - -if ([string]::IsNullOrWhiteSpace($WinISONoPromptPath)) { - $srcDir = Split-Path -Parent $WinISO - $srcBase = [IO.Path]::GetFileNameWithoutExtension($WinISO) - $WinISONoPromptPath = Join-Path $srcDir "$srcBase.patched.iso" -} +Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")' -Step 4 $srcMtime = (Get-Item $WinISO).LastWriteTime $rebuild = $true @@ -747,21 +885,24 @@ if ($rebuild) { Assert-Step 'NoPromptIso' "no-prompt ISO present: $WinISONoPromptPath" { Test-Path $WinISONoPromptPath } Assert-Step 'NoPromptIso' 'no-prompt ISO non-empty (>500 MB)' { (Get-Item $WinISONoPromptPath).Length -gt 500MB } +} # end Step 4 + # ───────────────────────────────────────────────────────────────────────────── # Step 5: generate VMX # ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 5: generate VMX' +Write-Step 'Step 5: generate VMX' -Step 5 +if ($StartFromStep -le 5) { # guestOS: # "windows2019srvnext-64" is the generic forward-compat ID for new Windows # Server releases on Workstation. Workstation 17.6+ accepts it for Server 2025. # If your Workstation version supports the explicit ID, change it here. -$guestOS = 'windows2019srvnext-64' +# ($guestOS is set unconditionally in the derived-paths block above.) # CD-ROM ordering (sata): # sata0:0 = Windows install ISO (bootable) ← EFI picks this on first boot -# sata0:1 = autounattend ISO (non-bootable, scanned by Setup) -# sata0:2 = VMware Tools ISO (Tools installer source) +# sata0:1 = autounattend ISO (non-bootable, scanned by Setup; Tools bundled inside) +# Both disconnected in Step 8 after install — snapshot/clone boots clean with no ISOs. $vmxLines = @( '.encoding = "windows-1252"', 'config.version = "8"', @@ -804,7 +945,7 @@ $vmxLines = @( "nvme0:0.fileName = `"$vmdkName`"", 'nvme0:0.deviceType = "disk"', '', - '# Three SATA CD-ROMs: install / autounattend / Tools', + '# Two SATA CD-ROMs: install ISO + autounattend ISO (Tools bundled in autounattend)', 'sata0.present = "TRUE"', 'sata0.pciSlotNumber = "32"', 'sata0:0.present = "TRUE"', @@ -815,10 +956,6 @@ $vmxLines = @( 'sata0:1.deviceType = "cdrom-image"', "sata0:1.fileName = `"$autounattendIso`"", 'sata0:1.startConnected = "TRUE"', - 'sata0:2.present = "TRUE"', - 'sata0:2.deviceType = "cdrom-image"', - "sata0:2.fileName = `"$ToolsISO`"", - 'sata0:2.startConnected = "TRUE"', '', '# NIC: e1000e for install (broad compat); switched to vmxnet3 after Tools', 'ethernet0.present = "TRUE"', @@ -852,19 +989,23 @@ $vmxLines = @( Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath } +} # end Step 5 + # ───────────────────────────────────────────────────────────────────────────── # Step 6: power on VM # ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 6: power on VM (vmrun start nogui)' -Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null -Start-Sleep -Seconds 5 -$running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors -Assert-Step 'PowerOn' 'VM appears in vmrun list' { $running -match [regex]::Escape($VMXPath) } +Write-Step "Step 6: power on VM (vmrun start $vmrunUiMode)" -Step 6 +if ($StartFromStep -le 6) { +Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async +Start-Sleep -Seconds 10 +Assert-Step 'PowerOn' 'VM appears in vmrun list' { Test-VmInList -VmxPath $VMXPath } +} # end Step 6 # ───────────────────────────────────────────────────────────────────────────── # Step 7: wait for install_complete.flag inside guest # ───────────────────────────────────────────────────────────────────────────── -Write-Step "Step 7: wait for guest install_complete.flag (max ${GuestPollMaxMinutes} min)" +Write-Step "Step 7: wait for guest install_complete.flag (max ${GuestPollMaxMinutes} min)" -Step 7 +if ($StartFromStep -le 7) { $deadline = (Get-Date).AddMinutes($GuestPollMaxMinutes) $flagPath = 'C:\Windows\Temp\install_complete.flag' @@ -883,22 +1024,24 @@ while ((Get-Date) -lt $deadline) { } Assert-Step 'GuestReady' "install_complete.flag observed in guest" { $flagSeen } -# ───────────────────────────────────────────────────────────────────────────── -# Step 8: graceful soft-stop, swap NIC e1000e → vmxnet3 -# ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 8: stop VM softly, swap NIC to vmxnet3' +} # end Step 7 -Invoke-Vmrun -Arguments @('-T','ws','stop',$VMXPath,'soft') -IgnoreErrors | Out-Null +# ───────────────────────────────────────────────────────────────────────────── +# Step 8: stop VM, disconnect ISOs, swap NIC e1000e → vmxnet3 +# ───────────────────────────────────────────────────────────────────────────── +Write-Step 'Step 8: stop VM, disconnect ISOs, swap NIC to vmxnet3' -Step 8 +if ($StartFromStep -le 8) { -# Wait for VM to drop out of running list (max 3 min). -$stopDeadline = (Get-Date).AddMinutes(3) -do { - Start-Sleep -Seconds 5 - $still = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors -} while ((Get-Date) -lt $stopDeadline -and ($still -match [regex]::Escape($VMXPath))) -Assert-Step 'Stop' 'VM no longer in vmrun list' { - $list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors - -not ($list -match [regex]::Escape($VMXPath)) +Stop-Vm -VmxPath $VMXPath +Assert-Step 'Stop' 'VM no longer in vmrun list' { -not (Test-VmInList -VmxPath $VMXPath) } + +# Remove CD-ROM controller and USB from VMX — not needed post-install. +foreach ($prefix in 'sata0','usb','ehci','usb_xhci') { + Remove-VmxLines -VmxPath $VMXPath -KeyPrefix $prefix +} +Assert-Step 'CleanVmx' 'CD-ROM and USB entries removed from VMX' { + $vmx = Get-Content -LiteralPath $VMXPath -Raw + ($vmx -notmatch '(?m)^\s*sata0') -and ($vmx -notmatch '(?m)^\s*usb') } Set-VmxKey -VmxPath $VMXPath -Key 'ethernet0.virtualDev' -Value 'vmxnet3' @@ -906,12 +1049,16 @@ Assert-Step 'NicSwap' 'ethernet0.virtualDev = vmxnet3' { (Get-Content -LiteralPath $VMXPath) -match '^\s*ethernet0\.virtualDev\s*=\s*"vmxnet3"\s*$' } +} # end Step 8 + # ───────────────────────────────────────────────────────────────────────────── # Step 9: power on, verify vmxnet3 + DHCP IP # ───────────────────────────────────────────────────────────────────────────── -Write-Step 'Step 9: power on with vmxnet3, verify DHCP IP' +Write-Step 'Step 9: power on with vmxnet3, verify DHCP IP' -Step 9 +if ($StartFromStep -le 9) { -Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null +Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async +Start-Sleep -Seconds 10 $ipDeadline = (Get-Date).AddMinutes(10) $guestIP = $null do { @@ -921,27 +1068,23 @@ do { } while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+')) Assert-Step 'IPCheck' 'Guest IP obtained on vmxnet3' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' } +} # end Step 9 + # ───────────────────────────────────────────────────────────────────────────── # Step 10: shutdown → snapshot (cold) → power on # ───────────────────────────────────────────────────────────────────────────── -Write-Step "Step 10: graceful shutdown, snapshot '$SnapshotName' (cold), power on" +Write-Step "Step 10: graceful shutdown, snapshot '$SnapshotName' (cold), power on" -Step 10 +if ($StartFromStep -le 10) { -Invoke-Vmrun -Arguments @('-T','ws','stop',$VMXPath,'soft') -IgnoreErrors | Out-Null -$stopDeadline = (Get-Date).AddMinutes(3) -do { - Start-Sleep -Seconds 5 - $running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors -} while ((Get-Date) -lt $stopDeadline -and ($running -match [regex]::Escape($VMXPath))) -Assert-Step 'Snapshot' 'VM powered off before snapshot' { - $list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors - -not ($list -match [regex]::Escape($VMXPath)) -} +Stop-Vm -VmxPath $VMXPath +Assert-Step 'Snapshot' 'VM powered off before snapshot' { -not (Test-VmInList -VmxPath $VMXPath) } Invoke-Vmrun -Arguments @('-T','ws','snapshot',$VMXPath,$SnapshotName) | Out-Null $snaps = Invoke-Vmrun -Arguments @('-T','ws','listSnapshots',$VMXPath) Assert-Step 'Snapshot' "Snapshot '$SnapshotName' present" { $snaps -match [regex]::Escape($SnapshotName) } -Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null +Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async +Start-Sleep -Seconds 10 $ipDeadline = (Get-Date).AddMinutes(5) do { Start-Sleep -Seconds 10 @@ -949,6 +1092,8 @@ do { } while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+')) Assert-Step 'Snapshot' 'VM back online after snapshot' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' } +} # end Step 10 + # ───────────────────────────────────────────────────────────────────────────── # Final summary # ───────────────────────────────────────────────────────────────────────────── diff --git a/template/Prepare-WinBuild2025.ps1 b/template/Prepare-WinBuild2025.ps1 index a2c1481..c96e07a 100644 --- a/template/Prepare-WinBuild2025.ps1 +++ b/template/Prepare-WinBuild2025.ps1 @@ -437,7 +437,17 @@ try { Write-Host "[Prepare] Output from guest:" Write-Host "------------------------------------------------------------" - $result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs + $result = $null + try { + $result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs + } catch [System.Management.Automation.Remoting.PSRemotingTransportException] { + # VM rebooted mid-update (WU triggered OS restart before Setup could return 3010). + # Treat as reboot-required: close dead session, fall through to the reboot-wait block. + Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..." + Remove-PSSession $session -ErrorAction SilentlyContinue + $session = $null + $result = 3010 + } Write-Host "------------------------------------------------------------" Write-Host "[Prepare] Iteration $iter exit code: $result" @@ -448,18 +458,19 @@ try { throw "Setup-WinBuild2025.ps1 exited with code $result" } - # exit 3010 → WU applied updates needing reboot. Reboot guest, wait, - # reopen session, loop. -SkipWindowsUpdate must NOT be set on retry — - # we want the next iteration to drain remaining updates. + # exit 3010 (or transport error treated as 3010) → WU needs reboot. + # Reboot guest if session still alive, wait for WinRM, reopen session, loop. if ($iter -eq $maxWuIterations) { throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)" } Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..." - try { - Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue - } catch { } - Remove-PSSession $session -ErrorAction SilentlyContinue + if ($session) { + try { + Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue + } catch { } + Remove-PSSession $session -ErrorAction SilentlyContinue + } Start-Sleep -Seconds 30 # let WinRM tear down before polling Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..." diff --git a/template/Setup-WinBuild2025.ps1 b/template/Setup-WinBuild2025.ps1 index 312dc91..ad51b39 100644 --- a/template/Setup-WinBuild2025.ps1 +++ b/template/Setup-WinBuild2025.ps1 @@ -360,6 +360,10 @@ Assert-Step 'CIUX' 'OOBE policy DisablePrivacyExperience=1' { Assert-Step 'CIUX' 'DataCollection AllowTelemetry=0' { (Get-ItemProperty -Path $dcKey -Name AllowTelemetry -ErrorAction Stop).AllowTelemetry -eq 0 } +Assert-Step 'CIUX' 'autologin Administrator enabled (AutoAdminLogon=1)' { + $wl = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop + $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' +} # ── Step 6: Windows Update ──────────────────────────────────────────────────── # Firewall + Defender already off: WU downloads and installs run without scanning. @@ -919,6 +923,12 @@ Write-Host "DISM exited (code $($dism.ExitCode))." Write-Host "Disk cleanup complete." +# DISM StartComponentCleanup / WaaSMedicSvc may reset service StartType during cleanup. +# Re-enforce Disabled on both WU services after DISM completes. +foreach ($svc in 'wuauserv', 'UsoSvc') { + Set-Service -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue +} + # Validation — leftover artifacts from this run should be gone # Note: do NOT assert SoftwareDistribution\Download empty. # WaaSMedicSvc (Windows Update Medic Service) is tamper-protected — cannot be stopped — @@ -929,7 +939,7 @@ $sdFiles = Get-ChildItem 'C:\Windows\SoftwareDistribution\Download' -Recurse -Fi $sdMB = [math]::Round(($sdFiles | Measure-Object -Property Length -Sum).Sum / 1MB, 1) Write-Host " SoftwareDistribution\Download: $($sdFiles.Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)." -Assert-Step 'Cleanup' 'wuauserv StartType still Disabled after cache clear' { +Assert-Step 'Cleanup' 'wuauserv StartType Disabled after cache clear' { (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' } } # end if (-not $SkipCleanup) diff --git a/template/Validate-DeployState.ps1 b/template/Validate-DeployState.ps1 new file mode 100644 index 0000000..dc718c0 --- /dev/null +++ b/template/Validate-DeployState.ps1 @@ -0,0 +1,186 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Validates that Deploy-WinBuild2025.ps1 has correctly configured a guest VM. + +.DESCRIPTION + Connects to the VM via WinRM (HTTP/Basic) and runs all checks that + Setup-WinBuild2025.ps1 Steps 1/2/3/4b/5b/5c assert as validation-only. + Use this after Deploy completes and before running Prepare/Setup. + +.PARAMETER VMIPAddress + IP address of the guest VM on VMnet8 (e.g. 192.168.79.129). + +.PARAMETER AdminUsername + Admin account inside the VM (default: Administrator). + +.PARAMETER AdminPassword + Password for the admin account. Prompted if omitted. + +.EXAMPLE + .\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $VMIPAddress, + [string] $AdminUsername = 'Administrator', + [string] $AdminPassword +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $AdminPassword) { + $secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString + $AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) +} + +$cred = New-Object System.Management.Automation.PSCredential( + $AdminUsername, + (ConvertTo-SecureString $AdminPassword -AsPlainText -Force) +) +$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck + +$prevAllowUnenc = (Get-Item WSMan:\localhost\Client\AllowUnencrypted).Value +$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value + +try { + Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force + $cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value + if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) { + Set-Item WSMan:\localhost\Client\TrustedHosts -Value ($cur ? "$cur,$VMIPAddress" : $VMIPAddress) -Force + } + + Write-Host "`nValidating Deploy state on $VMIPAddress..." -ForegroundColor Cyan + + $checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred ` + -Authentication Basic -SessionOption $so -ScriptBlock { + + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + function Chk { + param([string]$Name, [scriptblock]$Test) + try { $ok = [bool](& $Test); $err = '' } + catch { $ok = $false; $err = $_.Exception.Message } + $results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err }) + } + + # ── Firewall ───────────────────────────────────────────────────────── + foreach ($p in Get-NetFirewallProfile) { + $n = $p.Name; $e = $p.Enabled + Chk "Firewall $n disabled" { $e -eq $false } + } + + # ── Defender ───────────────────────────────────────────────────────── + Chk 'Defender GPO DisableAntiSpyware=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' ` + -Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1 + } + + # ── WinRM ───────────────────────────────────────────────────────────── + Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' } + Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' } + Chk 'WinRM AllowUnencrypted=true' { + (Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'true' + } + Chk 'WinRM Auth/Basic=true' { + (Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true' + } + Chk 'WinRM MaxMemoryPerShellMB >= 2048' { + [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048 + } + + # ── UAC ─────────────────────────────────────────────────────────────── + $polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' + Chk 'UAC EnableLUA=0' { + (Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0 + } + Chk 'UAC LocalAccountTokenFilterPolicy=1' { + (Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1 + } + + # ── Explorer ────────────────────────────────────────────────────────── + Chk 'Explorer LaunchTo=1 (HKCU)' { + (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` + -Name LaunchTo -EA Stop).LaunchTo -eq 1 + } + + # ── UX hardening ───────────────────────────────────────────────────── + Chk 'NoLockScreen=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' ` + -Name NoLockScreen -EA Stop).NoLockScreen -eq 1 + } + Chk 'Server Manager policy DoNotOpenAtLogon=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' ` + -Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1 + } + Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' ` + -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 + } + Chk 'Server Manager task Disabled' { + $t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' ` + -TaskName 'ServerManager' -EA SilentlyContinue + (-not $t) -or ($t.State -eq 'Disabled') + } + Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' { + (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' ` + -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 + } + Chk 'DisableCAD=1 (Policies\System)' { + (Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1 + } + Chk 'OOBE DisablePrivacyExperience=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' ` + -Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1 + } + Chk 'DataCollection AllowTelemetry=0' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' ` + -Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0 + } + Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' { + $wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop + $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' + } + + # ── Windows Update GPO + services ───────────────────────────────────── + Chk 'WU GPO NoAutoUpdate=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' ` + -Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1 + } + Chk 'WU GPO DisableWindowsUpdateAccess=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' ` + -Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1 + } + Chk 'wuauserv StartType=Manual' { (Get-Service wuauserv -EA Stop).StartType -eq 'Manual' } + Chk 'UsoSvc StartType=Manual' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Manual' } + + return $results.ToArray() + } + + Write-Host '' + $failed = 0 + foreach ($r in $checks) { + if ($r.Pass) { + Write-Host " [OK] $($r.Name)" -ForegroundColor Green + } else { + $detail = if ($r.Err) { " ($($r.Err))" } else { '' } + Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red + $failed++ + } + } + + Write-Host '' + if ($failed -eq 0) { + Write-Host "All $($checks.Count) Deploy checks passed." -ForegroundColor Green + exit 0 + } else { + Write-Host "$failed / $($checks.Count) Deploy checks FAILED." -ForegroundColor Red + exit 1 + } + +} finally { + Set-Item WSMan:\localhost\Client\AllowUnencrypted $prevAllowUnenc -Force -EA SilentlyContinue + Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue +} diff --git a/template/Validate-SetupState.ps1 b/template/Validate-SetupState.ps1 new file mode 100644 index 0000000..b62b30b --- /dev/null +++ b/template/Validate-SetupState.ps1 @@ -0,0 +1,259 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Validates full post-Setup state of a template VM (Deploy + Setup). + +.DESCRIPTION + Connects to the VM via WinRM (HTTP/Basic) and runs all checks for the + final BaseClean snapshot state: Deploy OS hardening + Setup CI toolchain. + Run this after Prepare-WinBuild2025.ps1 completes (exit 0) to confirm + the VM is ready for snapshot and production use. + +.PARAMETER VMIPAddress + IP address of the guest VM on VMnet8 (e.g. 192.168.79.129). + +.PARAMETER AdminUsername + Admin account inside the VM (default: Administrator). + +.PARAMETER AdminPassword + Password for the admin account. Prompted if omitted. + +.PARAMETER BuildUsername + CI build account created by Setup (default: ci_build). + +.PARAMETER DotNetChannel + Expected .NET SDK channel prefix (default: 10.0). + +.PARAMETER PythonVersion + Expected exact Python version string (default: 3.13.3). + +.EXAMPLE + .\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $VMIPAddress, + [string] $AdminUsername = 'Administrator', + [string] $AdminPassword, + [string] $BuildUsername = 'ci_build', + [string] $DotNetChannel = '10.0', + [string] $PythonVersion = '3.13.3' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not $AdminPassword) { + $secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString + $AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) +} + +$cred = New-Object System.Management.Automation.PSCredential( + $AdminUsername, + (ConvertTo-SecureString $AdminPassword -AsPlainText -Force) +) +$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck + +$prevAllowUnenc = (Get-Item WSMan:\localhost\Client\AllowUnencrypted).Value +$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value + +try { + Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force + $cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value + if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) { + Set-Item WSMan:\localhost\Client\TrustedHosts -Value ($cur ? "$cur,$VMIPAddress" : $VMIPAddress) -Force + } + + Write-Host "`nValidating full Setup state on $VMIPAddress..." -ForegroundColor Cyan + + $checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred ` + -Authentication Basic -SessionOption $so -ScriptBlock { + + param($BuildUsername, $DotNetChannel, $PythonVersion) + + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + function Chk { + param([string]$Name, [scriptblock]$Test) + try { $ok = [bool](& $Test); $err = '' } + catch { $ok = $false; $err = $_.Exception.Message } + $results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err }) + } + + # ════════════════════════════════════════════════════════════════════ + # DEPLOY checks (same as Validate-DeployState.ps1) + # ════════════════════════════════════════════════════════════════════ + + # Firewall + foreach ($p in Get-NetFirewallProfile) { + $n = $p.Name; $e = $p.Enabled + Chk "Firewall $n disabled" { $e -eq $false } + } + + # Defender + Chk 'Defender GPO DisableAntiSpyware=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' ` + -Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1 + } + + # WinRM + Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' } + Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' } + Chk 'WinRM AllowUnencrypted=true' { + (Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'true' + } + Chk 'WinRM Auth/Basic=true' { + (Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true' + } + Chk 'WinRM MaxMemoryPerShellMB >= 2048' { + [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048 + } + + # UAC + $polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' + Chk 'UAC EnableLUA=0' { + (Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0 + } + Chk 'UAC LocalAccountTokenFilterPolicy=1' { + (Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1 + } + + # Explorer + Chk 'Explorer LaunchTo=1 (HKCU)' { + (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` + -Name LaunchTo -EA Stop).LaunchTo -eq 1 + } + + # UX hardening + Chk 'NoLockScreen=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' ` + -Name NoLockScreen -EA Stop).NoLockScreen -eq 1 + } + Chk 'Server Manager policy DoNotOpenAtLogon=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' ` + -Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1 + } + Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' ` + -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 + } + Chk 'Server Manager task Disabled' { + $t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' ` + -TaskName 'ServerManager' -EA SilentlyContinue + (-not $t) -or ($t.State -eq 'Disabled') + } + Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' { + (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' ` + -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 + } + Chk 'DisableCAD=1 (Policies\System)' { + (Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1 + } + Chk 'OOBE DisablePrivacyExperience=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' ` + -Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1 + } + Chk 'DataCollection AllowTelemetry=0' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' ` + -Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0 + } + Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' { + $wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop + $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' + } + + # ════════════════════════════════════════════════════════════════════ + # SETUP checks (post-Prepare state) + # ════════════════════════════════════════════════════════════════════ + + # CI build user + Chk "user $BuildUsername exists" { + $null -ne (Get-LocalUser -Name $BuildUsername -EA Stop) + } + Chk "user $BuildUsername enabled" { + (Get-LocalUser -Name $BuildUsername -EA Stop).Enabled + } + Chk "user $BuildUsername PasswordNeverExpires" { + (Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null + } + Chk "user $BuildUsername member of Administrators" { + [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) + } + + # CI directories + foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') { + Chk "$dir exists" { Test-Path $dir -PathType Container } + } + + # Windows Update permanently disabled + Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' } + Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' } + Chk 'WU GPO NoAutoUpdate=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' ` + -Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1 + } + Chk 'WU GPO DisableWindowsUpdateAccess=1' { + (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' ` + -Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1 + } + + # Toolchain + Chk ".NET SDK channel $DotNetChannel" { + $v = (& dotnet --version 2>&1) -as [string] + $v -and $v.StartsWith($DotNetChannel) + } + Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' } + Chk "Python version $PythonVersion" { + $v = (& 'C:\Python\python.exe' --version 2>&1) -as [string] + $v -and $v.Trim() -eq "Python $PythonVersion" + } + Chk 'MSBuild.exe present' { + $msb = Get-Command msbuild -ErrorAction SilentlyContinue + $msb -and (Test-Path $msb.Source) + } + + # No leftover installer files in C:\CI root + Chk 'no leftover installer scripts in C:\CI' { + $leftovers = Get-ChildItem 'C:\CI' -File -EA SilentlyContinue | + Where-Object { $_.Name -match 'wu_worker|vs_buildtools|dotnet-install|Setup-WinBuild' } + $leftovers.Count -eq 0 + } + + return $results.ToArray() + + } -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion + + Write-Host '' + $failed = 0 + $deployCnt = 0 + $setupCnt = 0 + $inSetup = $false + + foreach ($r in $checks) { + if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") { + Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan + $inSetup = $true + } + if ($r.Pass) { + Write-Host " [OK] $($r.Name)" -ForegroundColor Green + } else { + $detail = if ($r.Err) { " ($($r.Err))" } else { '' } + Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red + $failed++ + } + } + + Write-Host '' + if ($failed -eq 0) { + Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green + exit 0 + } else { + Write-Host "$failed / $($checks.Count) checks FAILED." -ForegroundColor Red + exit 1 + } + +} finally { + Set-Item WSMan:\localhost\Client\AllowUnencrypted $prevAllowUnenc -Force -EA SilentlyContinue + Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue +}