diff --git a/template/Deploy-WinBuild2022.ps1 b/template/Deploy-WinBuild2022.ps1 new file mode 100644 index 0000000..bf1c36f --- /dev/null +++ b/template/Deploy-WinBuild2022.ps1 @@ -0,0 +1,1116 @@ +#Requires -RunAsAdministrator +#Requires -Version 5.1 +<# +.SYNOPSIS + Deploys an unattended Windows Server 2022 VM on VMware Workstation + and installs VMware Tools automatically. + +.DESCRIPTION + Host-side orchestrator. Produces a ready-to-use template VM: + Step 1 — Validate prerequisites (vmrun, vmware-vdiskmanager, ISOs) + Step 2 — Render autounattend.xml from template + post-install.ps1 + (placeholders substituted with parameter values) + Step 3 — Build autounattend ISO (autounattend.xml + post-install.ps1) + 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, + 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, 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) + + NIC strategy: + Boot install with e1000e (broad OOB Windows compatibility). VMware Tools + install brings the vmxnet3 driver. Post-Tools, the host swaps the .vmx + to vmxnet3 and reboots. This avoids driver injection in WinPE. + + Disk layout (UEFI/GPT, 80 GB): + Part 1: EFI 100 MB FAT32 S: + Part 2: MSR 16 MB + Part 3: Primary rest NTFS C: ← Windows installs here + No Recovery partition (per user choice). + + Hardening done by post-install.ps1 inside the guest: + - UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1) + - Defender real-time protection off + DisableAntiSpyware=1 GPO + - DiagTrack/Telemetry off + - Windows Update GPO locks (NoAutoUpdate=1, DisableWindowsUpdateAccess=1); + services remain Manual — Setup-WinBuild2022 Step 6b permanently disables + them after applying updates (§7.2 Strategy B) + - Windows Firewall all profiles disabled (VMnet8 NAT lab; Setup Step 1 validates) + - RDP enabled (no NLA, lab only) + firewall rules for RDP/ICMP/WinRM-HTTPS + - WinRM HTTPS 5986 only (self-signed cert), Basic auth, AllowUnencrypted=false, + TrustedHosts=*, MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=25 + - ICMPv4 inbound allowed + - Lock screen disabled, Server Manager (policy + HKLM + task + HKCU + Default hive), + DisableCAD (Policies\System + Winlogon), OOBE/telemetry suppressed; + Setup-WinBuild2022 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 + +.PARAMETER WinISO + Path to the Windows Server 2022 install ISO. + +.PARAMETER ToolsISO + 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. + +.PARAMETER VMName + Display name in the Workstation library. Defaults to the .vmx file basename. + +.PARAMETER ProductKey + Windows Server 2022 product key to embed in autounattend. + +.PARAMETER ComputerName + Hostname. Max 15 chars, no spaces. + +.PARAMETER AdminUser +.PARAMETER AdminPassword + Local Administrator account credentials. + +.PARAMETER ImageIndex + Index of the install.wim edition to deploy. Default: 2 (Server 2022 + Standard Desktop Experience on the VOL ISO). Verify with: + dism /Get-WimInfo /WimFile:\sources\install.wim + +.PARAMETER DiskSizeGB + Virtual disk size in GiB. Default: 80. + +.PARAMETER MemoryMB + Guest RAM in MiB. Default: 6144 (6 GB). + +.PARAMETER VCPUCount + Total vCPU. Default: 2. + +.PARAMETER CoresPerSocket + Cores per socket. Default: 2 (=> 1 socket × 2 cores). + +.PARAMETER LocaleOS + OS UI / system locale. Default: en-US. + +.PARAMETER LocaleUser + User locale. Default: it-IT. + +.PARAMETER InputKeyboard + Keyboard layout id. Default: 0410:00000410 (Italian). + +.PARAMETER TimeZone + Windows time zone id. Default: W. Europe Standard Time. + +.PARAMETER SnapshotName + Snapshot name taken at the end. Default: PostInstall. + +.PARAMETER VMwareWorkstationDir + Override Workstation install dir if not at the default location. + +.PARAMETER GuestPollSeconds + Seconds between polls when waiting for install_complete.flag. Default: 30. + +.PARAMETER GuestPollMaxMinutes + Hard timeout waiting for install_complete.flag. Default: 90. + +.NOTES + Run from elevated PowerShell on the host. The VM IP detection (Step 9) + requires VMware Tools to be running in the guest — guaranteed because + Step 7 waited for the install_complete.flag dropped after Tools install. +#> +[CmdletBinding()] +param( + # ── ISOs ── + [string] $WinISO = 'F:\CI\ISO\en-us_windows_server_2022_updated_april_2026_x64_dvd_a4d60e24.iso', + [string] $ToolsISO = 'F:\CI\ISO\VMware-tools-windows.iso', + + # Cache path for the rebuilt no-prompt Windows ISO. Reused across runs when + # newer than the source. Empty string => rebuild every run inside the VM dir. + [string] $WinISONoPromptPath = '', + + # ── VM identity ── + [string] $VMXPath = 'F:\CI\Templates\WinBuild2022\WinBuild2022.vmx', + [string] $VMName = 'WinBuild2022', + + # ── Windows install ── + [string] $ProductKey = 'VDYBN-27WPP-V4HQT-9VMD4-VMK7H', + [string] $ComputerName = 'WINBUILD-2022', + [string] $AdminUser = 'Administrator', + [string] $AdminPassword = 'WinBuild!', + [int] $ImageIndex = 2, + + # ── Hardware ── + [int] $DiskSizeGB = 80, + [int] $MemoryMB = 6144, + [int] $VCPUCount = 4, + [int] $CoresPerSocket = 2, + + # ── Locale ── + [string] $LocaleOS = 'en-US', + [string] $LocaleUser = 'it-IT', + [string] $InputKeyboard = '0410:00000410', + [string] $TimeZone = 'W. Europe Standard Time', + + # ── Finalization ── + [string] $SnapshotName = 'PostInstall', + + # ── Tooling ── + [string] $VMwareWorkstationDir = 'C:\Program Files (x86)\VMware\VMware Workstation', + + # ── Polling ── + [int] $GuestPollSeconds = 30, + [int] $GuestPollMaxMinutes = 90, + + # When set, vmrun powers on the VM with the Workstation GUI visible. + # Default: headless (nogui) for unattended pipelines. + [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' } + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +function Write-Step { + 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 { + param( + [Parameter(Mandatory)] [string] $StepName, + [Parameter(Mandatory)] [string] $Description, + [Parameter(Mandatory)] [scriptblock] $Test + ) + $ok = $false + try { $ok = [bool](& $Test) } + catch { throw "[$StepName] Validation threw on '$Description': $_" } + if (-not $ok) { throw "[$StepName] Validation failed: $Description" } + Write-Host " [OK] $Description" -ForegroundColor Green +} + +# IMAPI2FS-based ISO writer. No external deps. Inline C# bridges IStream → file. +$isoWriterCs = @" +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +public class IsoStreamWriter2 { + [DllImport("shlwapi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)] + static extern int SHCreateStreamOnFileEx(string pszFile, uint grfMode, uint dwAttributes, bool fCreate, IntPtr pstmTemplate, out IStream ppstm); + + public static IStream OpenFileAsStream(string path) { + IStream s; + // STGM_READ | STGM_SHARE_DENY_WRITE = 0x00000020 + int hr = SHCreateStreamOnFileEx(path, 0x20, 0, false, IntPtr.Zero, out s); + if (hr != 0) throw new Exception("SHCreateStreamOnFileEx 0x" + hr.ToString("X")); + return s; + } + + public static void Save(object streamObj, string path) { + IStream istream = streamObj as IStream; + if (istream == null) { + // Fallback: QueryInterface for IStream IID via Marshal. + IntPtr unk = Marshal.GetIUnknownForObject(streamObj); + try { + Guid iid = new Guid("0000000C-0000-0000-C000-000000000046"); + IntPtr pStream; + int hr = Marshal.QueryInterface(unk, ref iid, out pStream); + if (hr != 0) throw new InvalidCastException("QueryInterface(IStream) failed: 0x" + hr.ToString("X")); + try { + istream = (IStream)Marshal.GetObjectForIUnknown(pStream); + } finally { + Marshal.Release(pStream); + } + } finally { + Marshal.Release(unk); + } + } + using (FileStream fs = File.Open(path, FileMode.Create, FileAccess.Write)) { + byte[] buf = new byte[2048 * 16]; + IntPtr pcb = Marshal.AllocHGlobal(8); + try { + int n; + do { + istream.Read(buf, buf.Length, pcb); + n = Marshal.ReadInt32(pcb); + if (n > 0) fs.Write(buf, 0, n); + } while (n > 0); + } finally { + Marshal.FreeHGlobal(pcb); + } + } + } +} +"@ +if (-not ([System.Management.Automation.PSTypeName]'IsoStreamWriter2').Type) { + Add-Type -TypeDefinition $isoWriterCs -Language CSharp +} + +function New-IsoFromFolder { + param( + [Parameter(Mandatory)] [string] $SourceFolder, + [Parameter(Mandatory)] [string] $IsoPath, + [Parameter(Mandatory)] [string] $VolumeLabel + ) + if (Test-Path $IsoPath) { Remove-Item $IsoPath -Force } + $fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage + $result = $null + $stream = $null + try { + $fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF + $fsi.VolumeName = $VolumeLabel + $fsi.ChooseImageDefaultsForMediaType(13) # IMAPI_MEDIA_TYPE_DISK + $fsi.Root.AddTree($SourceFolder, $false) + $result = $fsi.CreateResultImage() + $stream = $result.ImageStream + [IsoStreamWriter2]::Save($stream, $IsoPath) + } finally { + # Release in reverse order. ImageStream holds file handles to source + # files added via AddTree — must be released before the source folder + # can be deleted. + if ($stream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($stream) | Out-Null } + if ($result) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($result) | Out-Null } + [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($fsi) | Out-Null + [GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect() + } +} + +function New-WinIsoNoPrompt { + <# + Rebuilds a Windows install ISO replacing the EFI boot image with + efisys_noprompt.bin (extracted from the source ISO itself). + Result: UEFI boot proceeds straight into Windows Setup, no + "Press any key to boot from CD" prompt. + #> + param( + [Parameter(Mandatory)] [string] $SourceIsoPath, + [Parameter(Mandatory)] [string] $OutputIsoPath + ) + if (Test-Path $OutputIsoPath) { Remove-Item $OutputIsoPath -Force } + + $mount = Mount-DiskImage -ImagePath $SourceIsoPath -PassThru -StorageType ISO -Access ReadOnly + try { + $vol = $mount | Get-Volume + $letter = "$($vol.DriveLetter):\" + $bootImg = Join-Path $letter 'efi\microsoft\boot\efisys_noprompt.bin' + if (-not (Test-Path $bootImg)) { + throw "efisys_noprompt.bin not present in source ISO at $bootImg" + } + $label = $vol.FileSystemLabel + if ([string]::IsNullOrWhiteSpace($label)) { $label = 'WINSERVER' } + if ($label.Length -gt 32) { $label = $label.Substring(0,32) } + + $fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage + try { + $fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF + $fsi.VolumeName = $label + $fsi.ChooseImageDefaultsForMediaType(13) + # 5+ GB ISO needs explicit large-volume defaults if available. + try { $fsi.UDFRevision = 0x102 } catch {} + + Write-Host " Adding ISO tree (this takes a few minutes for 5+ GB)..." -ForegroundColor Yellow + $fsi.Root.AddTree($letter, $false) + + $bootStream = [IsoStreamWriter2]::OpenFileAsStream($bootImg) + $bootOpts = New-Object -ComObject IMAPI2FS.BootOptions + $bootOpts.AssignBootImage($bootStream) + $bootOpts.Emulation = 0 # IMAPI_BOOT_EMULATION_TYPE_NONE + $bootOpts.PlatformId = 0xEF # EFI + $bootOpts.Manufacturer = 'Microsoft' + + # Single-element COM array for BootImageOptionsArray + $arr = New-Object 'object[]' 1 + $arr[0] = $bootOpts + try { $fsi.BootImageOptionsArray = $arr } + catch { $fsi.BootImageOptions = $bootOpts } # fallback older IMAPI2FS + + Write-Host " Building output ISO..." -ForegroundColor Yellow + $result = $fsi.CreateResultImage() + $stream = $result.ImageStream + [IsoStreamWriter2]::Save($stream, $OutputIsoPath) + } finally { + if ($stream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($stream) | Out-Null } + if ($result) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($result) | Out-Null } + if ($bootStream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($bootStream) | Out-Null } + if ($bootOpts) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($bootOpts) | Out-Null } + [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($fsi) | Out-Null + [GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect() + } + } finally { + Dismount-DiskImage -ImagePath $SourceIsoPath -ErrorAction SilentlyContinue | Out-Null + } +} + +function Invoke-Vmrun { + param([Parameter(Mandatory)] [string[]] $Arguments, + [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) { + throw "vmrun $($Arguments -join ' ') failed (exit $code): $stdout" + } + return $stdout +} + +function Set-VmxKey { + param( + [Parameter(Mandatory)] [string] $VmxPath, + [Parameter(Mandatory)] [string] $Key, + [Parameter(Mandatory)] [string] $Value + ) + $lines = Get-Content -LiteralPath $VmxPath + $pattern = "^\s*$([regex]::Escape($Key))\s*=" + $found = $false + $newLines = foreach ($line in $lines) { + if ($line -match $pattern) { $found = $true; "$Key = `"$Value`"" } + else { $line } + } + if (-not $found) { $newLines = @($newLines) + "$Key = `"$Value`"" } + 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 +# ───────────────────────────────────────────────────────────────────────────── +# ── 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 } +Assert-Step 'PreFlight' "Windows ISO exists: $WinISO" { Test-Path $WinISO } +Assert-Step 'PreFlight' "VMware Tools ISO exists: $ToolsISO" { Test-Path $ToolsISO } +Assert-Step 'PreFlight' "autounattend template exists: $xmlTemplate" { Test-Path $xmlTemplate } +Assert-Step 'PreFlight' "ComputerName valid (1-15 chars, no spaces)" { + $ComputerName -match '^[A-Za-z0-9-]{1,15}$' +} +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." +} + +} # end Step 1 + +# ───────────────────────────────────────────────────────────────────────────── +# 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 + +# autounattend.xml +$xmlText = Get-Content -LiteralPath $xmlTemplate -Raw +$xmlSubs = @{ + '{{LOCALE_OS}}' = $LocaleOS + '{{LOCALE_USER}}' = $LocaleUser + '{{KEYBOARD}}' = $InputKeyboard + '{{IMAGE_INDEX}}' = "$ImageIndex" + '{{PRODUCT_KEY}}' = $ProductKey + '{{COMPUTER_NAME}}' = $ComputerName + '{{TIMEZONE}}' = $TimeZone + '{{ADMIN_USER}}' = $AdminUser + '{{ADMIN_PWD}}' = $AdminPassword +} +foreach ($k in $xmlSubs.Keys) { $xmlText = $xmlText.Replace($k, $xmlSubs[$k]) } +$xmlOut = Join-Path $stagingDir 'autounattend.xml' +Set-Content -LiteralPath $xmlOut -Value $xmlText -Encoding UTF8 +Assert-Step 'Render' 'autounattend.xml written' { Test-Path $xmlOut } +Assert-Step 'Render' 'no placeholders left in xml' { -not (Select-String -Path $xmlOut -Pattern '\{\{[^}]+\}\}' -Quiet) } + +# post-install.ps1 — drops into autounattend ISO root, runs at FirstLogonCommands. +# Hardening + WinRM + Tools install + flag file + reboot. All paths are absolute. +$postInstall = @" +# Auto-generated by Deploy-WinBuild2022.ps1 — runs at first logon as Administrator. +`$ErrorActionPreference = 'Continue' +`$log = 'C:\Windows\Temp\post-install.log' +function L(`$m){ "`$(Get-Date -f s) `$m" | Tee-Object -FilePath `$log -Append } + +L 'post-install start' + +# ── KICK OFF VMware Tools install (async — wrapper detaches MSI child) ──── +# Started first so the MSI runs in the background while hardening tweaks +# below execute. Settled state verified at the very end before reboot. +`$selfDir = Split-Path -Parent `$MyInvocation.MyCommand.Path +`$toolsExe = `$null +foreach (`$name in 'setup64.exe','setup.exe') { + `$cand = Join-Path `$selfDir "Tools\`$name" + if (Test-Path `$cand) { `$toolsExe = `$cand; break } +} +if (-not `$toolsExe) { + L 'Bundled Tools not found, scanning CD drives...' + foreach (`$drv in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=5')) { + foreach (`$name in 'setup64.exe','setup.exe') { + `$cand = Join-Path (`$drv.DeviceID + '\') `$name + if (Test-Path `$cand) { `$toolsExe = `$cand; break } + } + if (`$toolsExe) { break } + } +} +L "Tools setup resolved to: `$toolsExe" + +`$localRoot = 'C:\Windows\Temp\VMwareTools' +`$msiLog = 'C:\Windows\Temp\vmtools-msi.log' +`$exitCode = -1 +if (`$toolsExe -and (Test-Path `$toolsExe)) { + # Copy payload to local writable disk — wrapper extract from CD fails at + # FirstLogon phase due to user profile %TEMP% ACL bootstrap timing. + `$srcRoot = Split-Path -Parent `$toolsExe + if (Test-Path `$localRoot) { Remove-Item `$localRoot -Recurse -Force } + L "Copying Tools payload `$srcRoot -> `$localRoot" + Copy-Item -Path `$srcRoot -Destination `$localRoot -Recurse -Force + + # PS argument parser strips inner quotes from /v"..."; --% stop-parsing + # token passes the tail literally to the EXE. + L "Tools cmd: .\setup.exe /s /v`"/qn REBOOT=R /l*v `$msiLog`"" + Push-Location `$localRoot + try { + & .\setup.exe --% /s /v"/qn REBOOT=R /l*v C:\Windows\Temp\vmtools-msi.log" + `$exitCode = `$LASTEXITCODE + } finally { Pop-Location } + L "VMware Tools wrapper exit code: `$exitCode (MSI continues async)" +} else { + L 'ERROR: VMware Tools setup not found (bundled or on any CD-ROM)' +} + +# ── UAC off (admin script automation) ───────────────────────────────────── +reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v EnableLUA /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f | Out-Null +L 'UAC disabled' + +# ── Defender disabled (lab only) — Setup-WinBuild2022 skips Defender step ─ +# RTP off + tamper-protect GPO. With Defender fully off, exclusion paths +# become moot (no scanner to exclude from), so Setup-WinBuild2022 has been +# stripped of its Defender step. +try { Set-MpPreference -DisableRealtimeMonitoring `$true -ErrorAction Stop; L 'Defender RTP off' } +catch { L "Defender RTP off failed: `$(`$_.Exception.Message)" } +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows Defender' /v DisableAntiSpyware /t REG_DWORD /d 1 /f | Out-Null + +# ── Telemetry / DiagTrack off ───────────────────────────────────────────── +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection' /v AllowTelemetry /t REG_DWORD /d 0 /f | Out-Null +sc.exe config DiagTrack start= disabled | Out-Null +sc.exe stop DiagTrack | Out-Null +L 'Telemetry disabled' + +# ── Windows Update GPO locks — services left Manual (§7.2 Strategy B) ─── +# 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 2022; 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 + 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 +# downloads. This VM lives on VMnet8 NAT behind the VMware NAT gateway — no +# inbound exposure from outside the host. Setup-WinBuild2022 Step 1 validates. +Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False +L 'Windows Firewall disabled on all profiles' +reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server' /v fDenyTSConnections /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' /v UserAuthentication /t REG_DWORD /d 0 /f | Out-Null +Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue +New-NetFirewallRule -DisplayName 'ICMPv4-In' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -ErrorAction SilentlyContinue | Out-Null +L 'RDP + ICMP allowed' + +# ── WinRM HTTPS-only/5986, Basic auth over HTTPS, TrustedHosts=* (lab only) ─ +# AllowUnencrypted intentionally left false — all WinRM traffic uses HTTPS/5986. +Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null +winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null +winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null +winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null +try { + `$cert = New-SelfSignedCertificate -DnsName `$env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My + New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint `$cert.Thumbprint -Force | Out-Null + New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any -RemoteAddress '192.168.79.0/24' | Out-Null + L 'WinRM HTTPS listener configured (port 5986, restricted to 192.168.79.0/24)' +} catch { L "WinRM HTTPS failed: `$(`$_.Exception.Message)" } +winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null +Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>`$null +Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>`$null +Set-Service -Name WinRM -StartupType Automatic 3>`$null +L 'WinRM shell limits: MaxMemory=2048MB IdleTimeout=2h MaxProcesses=25; StartType=Automatic' + +# ── Server Manager / WAC popup / first-logon UX ────────────────────────── +# Server Manager auto-start off — machine-wide + current Administrator + Default +# profile hive (so future cloned/sysprep'd users inherit). +reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null +reg add 'HKCU\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null +reg load HKU\DefaultUser 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null +reg add 'HKU\DefaultUser\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null +reg unload HKU\DefaultUser 2>&1 | Out-Null +# Windows Admin Center prompt at SM launch off +reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotPopWACConsoleAtSMLaunch /t REG_DWORD /d 1 /f | Out-Null +# DisableCAD — no Ctrl+Alt+Del at logon (CI VMs never need it) +reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null +# OOBE privacy experience suppressed +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null +L 'Server Manager + WAC + CAD + OOBE prompts disabled' +# Supplemental UX hardening validated by Setup-WinBuild2022 Step 5c: +# Lock screen: suppress at CI console sessions (no interactive user ever logs in) +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization' /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null +# Server Manager GPO policy key (authoritative path on WS2022; HKLM key above is not always honoured) +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' /v DoNotOpenAtLogon /t REG_DWORD /d 1 /f | Out-Null +# Server Manager scheduled task — the actual launcher on WS2022; registry alone insufficient +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, autologin Administrator, OOBE non-policy key set' + +# ── IE Enhanced Security Configuration off (Admin) ─────────────────────── +# Long-known CI papercut: ESC blocks every URL in admin sessions. +reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null +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 +powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c 2>&1 | Out-Null +powercfg /change monitor-timeout-ac 0 | Out-Null +powercfg /change disk-timeout-ac 0 | Out-Null +powercfg /change standby-timeout-ac 0 | Out-Null +powercfg /change hibernate-timeout-ac 0 | Out-Null +L 'Hibernate off, power plan High Performance' + +# ── Unneeded services off (CI build VM, not a print/search/file server) ── +foreach (`$svc in 'WSearch','Spooler','SysMain','WerSvc','RemoteRegistry','Fax','MapsBroker','RetailDemo','XblAuthManager','XblGameSave','XboxNetApiSvc','XboxGipSvc') { + `$s = Get-Service -Name `$svc -ErrorAction SilentlyContinue + if (`$s) { + sc.exe config `$svc start= disabled | Out-Null + sc.exe stop `$svc | Out-Null + L " service disabled: `$svc" + } +} + +# ── Windows Customer Experience tasks off ──────────────────────────────── +foreach (`$task in @( + '\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser', + '\Microsoft\Windows\Application Experience\ProgramDataUpdater', + '\Microsoft\Windows\Autochk\Proxy', + '\Microsoft\Windows\Customer Experience Improvement Program\Consolidator', + '\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip', + '\Microsoft\Windows\Feedback\Siuf\DmClient', + '\Microsoft\Windows\Feedback\Siuf\DmClientOnScenarioDownload' +)) { + schtasks /Change /TN `$task /Disable 2>&1 | Out-Null +} +L 'CEIP / feedback scheduled tasks disabled' + +# ── Azure Arc auto-start / tray icon off ──────────────────────────────── +# Server 2022 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) +# (b) vmtoolsd.exe on disk +# (c) VMTools service Running (not just registered — install finalized) +# Hard timeout 15 min. +if (`$toolsExe) { + `$installDeadline = (Get-Date).AddMinutes(15) + while ((Get-Date) -lt `$installDeadline) { + Start-Sleep -Seconds 5 + `$busy = Get-Process -ErrorAction SilentlyContinue | + Where-Object { `$_.ProcessName -in 'setup','setup64','VMware-Tools','VMware-Tools-Installer','vminst' } + `$vmtoolsd = Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe' + `$svc = Get-Service -Name VMTools -ErrorAction SilentlyContinue + `$svcRunning = (`$svc -and `$svc.Status -eq 'Running') + L " install poll: busy=`$(`$busy.Count) vmtoolsd=`$vmtoolsd svc=`$(if (`$svc) { `$svc.Status } else { 'absent' })" + if (-not `$busy -and `$vmtoolsd -and `$svcRunning) { L 'Tools install settled.'; break } + } + + if (Test-Path `$msiLog) { + L "---- MSI log tail (last 30 lines) ----" + Get-Content `$msiLog -Tail 30 | ForEach-Object { L `$_ } + L '---- end MSI log ----' + } else { + L "MSI log NOT created at `$msiLog (wrapper failed before MSI launched)" + } + + if (Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe') { + L 'vmtoolsd.exe present -> Tools installed OK' + } else { + L "WARNING: vmtoolsd.exe NOT present -> install failed (wrapper exit `$exitCode)" + } +} + +# ── Drop completion flag (host polls for this via vmrun) ────────────────── +New-Item -ItemType File -Path 'C:\Windows\Temp\install_complete.flag' -Force | Out-Null +L 'install_complete.flag dropped' + +# ── 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 +Assert-Step 'Render' 'post-install.ps1 written' { Test-Path $psOut } + +# Bundle VMware Tools contents under autounattend\Tools\ so post-install.ps1 +# always finds setup64.exe on the same CD it boots from. Avoids the +# "third SATA CD-ROM disconnected at first logon" failure mode. +Write-Host ' Extracting VMware Tools ISO into autounattend staging...' -ForegroundColor Yellow +$toolsStage = Join-Path $stagingDir 'Tools' +New-Item -ItemType Directory -Path $toolsStage -Force | Out-Null +$toolsMount = Mount-DiskImage -ImagePath $ToolsISO -PassThru -Access ReadOnly +try { + $toolsLetter = "$(($toolsMount | Get-Volume).DriveLetter):\" + Copy-Item -Path (Join-Path $toolsLetter '*') -Destination $toolsStage -Recurse -Force +} finally { + Dismount-DiskImage -ImagePath $ToolsISO -ErrorAction SilentlyContinue | Out-Null +} +Assert-Step 'Render' 'Tools setup bundled in staging\Tools' { + (Test-Path (Join-Path $toolsStage 'setup.exe')) -or + (Test-Path (Join-Path $toolsStage 'setup64.exe')) +} + +} # end Step 2 + +# ───────────────────────────────────────────────────────────────────────────── +# Step 3: build autounattend ISO +# ───────────────────────────────────────────────────────────────────────────── +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 } +Assert-Step 'IsoBuild' 'autounattend.iso non-empty' { (Get-Item $autounattendIso).Length -gt 4096 } + +# Cleanup staging — only ISO matters from here on. IMAPI2FS may hold a +# transient lock on bundled binaries; force GC then retry, then ignore. +[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect() +Start-Sleep -Seconds 2 +Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue +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 + no-prompt Windows ISO +# ───────────────────────────────────────────────────────────────────────────── +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. +$vdiskArgs = @('-c', '-s', "${DiskSizeGB}GB", '-a', 'lsilogic', '-t', '0', $vmdkPath) +& $vdiskMgr @vdiskArgs +if ($LASTEXITCODE -ne 0) { throw "vmware-vdiskmanager failed (exit $LASTEXITCODE)" } +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")' -Step 4 + +$srcMtime = (Get-Item $WinISO).LastWriteTime +$rebuild = $true +if (Test-Path $WinISONoPromptPath) { + if ((Get-Item $WinISONoPromptPath).LastWriteTime -ge $srcMtime) { + Write-Host " Cached no-prompt ISO is current, reusing: $WinISONoPromptPath" -ForegroundColor Green + $rebuild = $false + } +} +if ($rebuild) { + Write-Host " Building no-prompt ISO from $WinISO" -ForegroundColor Yellow + Write-Host " Output: $WinISONoPromptPath" -ForegroundColor Yellow + New-WinIsoNoPrompt -SourceIsoPath $WinISO -OutputIsoPath $WinISONoPromptPath +} +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' -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 2022. +# If your Workstation version supports the explicit ID, change it here. +# ($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; Tools bundled inside) +# Both disconnected in Step 8 after install — snapshot/clone boots clean with no ISOs. +$vmxLines = @( + '.encoding = "windows-1252"', + 'config.version = "8"', + 'virtualHW.version = "21"', + "displayName = `"$VMName`"", + "guestOS = `"$guestOS`"", + 'firmware = "efi"', + 'uefi.secureBoot.enabled = "FALSE"', + "memSize = `"$MemoryMB`"", + "numvcpus = `"$VCPUCount`"", + "cpuid.coresPerSocket = `"$CoresPerSocket`"", + 'tools.syncTime = "TRUE"', + 'tools.upgrade.policy = "manual"', + 'powerType.powerOff = "soft"', + 'powerType.powerOn = "soft"', + 'powerType.suspend = "soft"', + 'powerType.reset = "soft"', + 'uuid.action = "create"', + '', + '# PCIe root ports — required for explicit PCI slot assignments below.', + '# Each pcieRootPort exposes 8 slots → enough headroom for NVMe + SATA + NIC.', + 'pciBridge0.present = "TRUE"', + 'pciBridge4.present = "TRUE"', + 'pciBridge4.virtualDev = "pcieRootPort"', + 'pciBridge4.functions = "8"', + 'pciBridge5.present = "TRUE"', + 'pciBridge5.virtualDev = "pcieRootPort"', + 'pciBridge5.functions = "8"', + 'pciBridge6.present = "TRUE"', + 'pciBridge6.virtualDev = "pcieRootPort"', + 'pciBridge6.functions = "8"', + 'pciBridge7.present = "TRUE"', + 'pciBridge7.virtualDev = "pcieRootPort"', + 'pciBridge7.functions = "8"', + '', + '# NVMe controller + 80 GB system disk', + 'nvme0.present = "TRUE"', + 'nvme0.pciSlotNumber = "224"', + 'nvme0:0.present = "TRUE"', + "nvme0:0.fileName = `"$vmdkName`"", + 'nvme0:0.deviceType = "disk"', + '', + '# Two SATA CD-ROMs: install ISO + autounattend ISO (Tools bundled in autounattend)', + 'sata0.present = "TRUE"', + 'sata0.pciSlotNumber = "32"', + 'sata0:0.present = "TRUE"', + 'sata0:0.deviceType = "cdrom-image"', + "sata0:0.fileName = `"$WinISONoPromptPath`"", + 'sata0:0.startConnected = "TRUE"', + 'sata0:1.present = "TRUE"', + 'sata0:1.deviceType = "cdrom-image"', + "sata0:1.fileName = `"$autounattendIso`"", + 'sata0:1.startConnected = "TRUE"', + '', + '# NIC: e1000e for install (broad compat); switched to vmxnet3 after Tools', + 'ethernet0.present = "TRUE"', + 'ethernet0.virtualDev = "e1000e"', + 'ethernet0.connectionType = "nat"', + 'ethernet0.addressType = "generated"', + 'ethernet0.startConnected = "TRUE"', + 'ethernet0.pciSlotNumber = "192"', + '', + '# USB / video', + 'usb.present = "TRUE"', + 'usb.pciSlotNumber = "34"', + 'ehci.present = "TRUE"', + 'ehci.pciSlotNumber = "35"', + 'usb_xhci.present = "TRUE"', + 'usb_xhci.pciSlotNumber = "33"', + 'svga.present = "TRUE"', + 'svga.graphicsMemoryKB = "262144"', + '', + '# No floppy / no sound (CI VM)', + 'floppy0.present = "FALSE"', + 'sound.present = "FALSE"', + '', + '# Misc', + 'vmci0.present = "TRUE"', + 'vmci0.pciSlotNumber = "36"', + 'hpet0.present = "TRUE"', + 'mks.enable3d = "FALSE"', + 'snapshot.action = "keep"' +) +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 $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)" -Step 7 +if ($StartFromStep -le 7) { + +$deadline = (Get-Date).AddMinutes($GuestPollMaxMinutes) +$flagPath = 'C:\Windows\Temp\install_complete.flag' +$flagSeen = $false +while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds $GuestPollSeconds + $toolsState = Invoke-Vmrun -Arguments @('-T','ws','checkToolsState',$VMXPath) -IgnoreErrors + Write-Host (" [{0:HH:mm:ss}] toolsState={1}" -f (Get-Date), ($toolsState -join ' ')) + if ($toolsState -notmatch 'running') { continue } + + # Tools running → can probe filesystem with guest creds. + $probe = & $vmrunPath -T ws -gu $AdminUser -gp $AdminPassword ` + fileExistsInGuest $VMXPath $flagPath 2>&1 + Write-Host (" fileExistsInGuest -> {0}" -f ($probe -join ' ')) + if ($probe -match 'The file exists') { $flagSeen = $true; break } +} +Assert-Step 'GuestReady' "install_complete.flag observed in guest" { $flagSeen } + +} # end Step 7 + +# ───────────────────────────────────────────────────────────────────────────── +# 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) { + +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' +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' -Step 9 +if ($StartFromStep -le 9) { + +Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async +Start-Sleep -Seconds 10 +$ipDeadline = (Get-Date).AddMinutes(10) +$guestIP = $null +do { + Start-Sleep -Seconds 10 + $guestIP = Invoke-Vmrun -Arguments @('-T','ws','getGuestIPAddress',$VMXPath,'-wait') -IgnoreErrors + Write-Host " guestIP probe -> $guestIP" +} 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" -Step 10 +if ($StartFromStep -le 10) { + +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) -Async +Start-Sleep -Seconds 10 +$ipDeadline = (Get-Date).AddMinutes(5) +do { + Start-Sleep -Seconds 10 + $guestIP = Invoke-Vmrun -Arguments @('-T','ws','getGuestIPAddress',$VMXPath,'-wait') -IgnoreErrors +} 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 +# ───────────────────────────────────────────────────────────────────────────── +Write-Step 'Done' +Write-Host "" +Write-Host " VMX : $VMXPath" +Write-Host " VMDK : $vmdkPath" +Write-Host " Hostname : $ComputerName" +Write-Host " Guest IP : $guestIP" +Write-Host " Admin user : $AdminUser" +Write-Host " Snapshot : $SnapshotName" +Write-Host "" +Write-Host " RDP : mstsc /v:$guestIP" +Write-Host " WinRM HTTPS : 5986 (self-signed, AllowUnencrypted=false)" +Write-Host "" +Write-Host " Next steps (from template\ dir):" +Write-Host " .\Validate-DeployState.ps1 -VMIPAddress $guestIP" +Write-Host " .\Prepare-WinBuild2022.ps1 -VMIPAddress $guestIP [-SkipWindowsUpdate]" +Write-Host "" +Write-Host " VM is powered on with vmxnet3 + DHCP. Snapshot '$SnapshotName' taken." -ForegroundColor Green diff --git a/template/Prepare-WinBuild2022.ps1 b/template/Prepare-WinBuild2022.ps1 new file mode 100644 index 0000000..fa129df --- /dev/null +++ b/template/Prepare-WinBuild2022.ps1 @@ -0,0 +1,668 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + HOST-side script: delivers and runs Setup-WinBuild2022.ps1 inside the template VM. + +.DESCRIPTION + Automates the template VM provisioning from the host machine: + 1. Pre-flight validation: script presence, IP octet range, TCP/5986 reachable + 2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) — restored on exit + 3. Validates host WinRM TrustedHosts applied correctly + 4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck — fails fast with actionable message + (PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe) + 5. PSSession open — step 4 and 5 are now merged + 6. Ensures C:\CI exists on guest; validates creation + 7. Copies Setup-WinBuild2022.ps1 into the VM; validates file present + size match + 8. Runs Setup-WinBuild2022.ps1 inside the VM with the given parameters + (Setup performs per-step validation internally — aborts on any failure) + 9. Handles the reboot-required case (exit 3010) and optionally re-runs + 10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command + (WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs) + 11. Restores host TrustedHosts in finally block + + Every validation step uses Assert-Step: prints [OK] on success, throws on failure. + The snapshot should only be taken after this script exits 0. + + PREREQUISITES (do these manually before running this script): + a. VM NIC is set to VMnet8 (NAT) — the VM needs internet for downloads + b. Windows Server is installed and booted + c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run: + winrm quickconfig -q + Enable-PSRemoting -Force -SkipNetworkProfileCheck + d. You know the IP address the VM got from DHCP on VMnet8 (NAT) + (check via: ipconfig inside the VM, or VMware → VM → Settings → + Network Adapter → Advanced → MAC address, then check DHCP leases) + + AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed): + 1. Shut down the VM + 2. Take snapshot: VM → Snapshot → Take Snapshot → name: BaseClean + (VM stays on VMnet8 NAT — internet access is required for builds) + 3. Store credentials on host (use the same password chosen during provisioning): + New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' ` + -Password '' -Persist LocalMachine + +.PARAMETER VMXPath + Full path to the template VM's .vmx file (e.g. F:\CI\Templates\WinBuild\WinBuild.vmx). + When provided: + - If the VM is powered off, the script starts it automatically via vmrun. + - The guest IP is detected automatically via vmrun getGuestIPAddress (requires + VMware Tools installed in the guest). + - At the end, the snapshot is created automatically after provisioning. + Either -VMXPath or -VMIPAddress (or both) must be supplied. + +.PARAMETER VMIPAddress + IP address of the template VM on VMnet8 NAT. + Optional when -VMXPath is provided (IP is auto-detected via VMware Tools). + Required when -VMXPath is omitted. + Example: 192.168.79.130 + +.PARAMETER AdminUsername + Administrator username inside the VM. Default: Administrator + +.PARAMETER AdminPassword + Administrator password inside the VM. If not supplied, prompts interactively. + +.PARAMETER BuildPassword + Password to set for the ci_build user inside the VM. + Must not be empty — script prompts interactively if not supplied. + +.PARAMETER BuildUsername + Local username for the CI build account inside the VM. Default: ci_build. + Passed through to Setup-WinBuild2022.ps1 and used in post-setup validation. + +.PARAMETER DotNetSdkVersion + .NET SDK channel to install in the VM. Default: 10.0 (matches host SDK). + +.PARAMETER SkipWindowsUpdate + Pass through to Setup-WinBuild2022.ps1 to skip the Windows Update step. + +.PARAMETER SnapshotName + Name of the snapshot to create. Default: BaseClean (case-sensitive — used by New-BuildVM.ps1). + Only relevant when -TakeSnapshot is set. + +.PARAMETER StoreCredential + After provisioning completes, store BuildUsername/BuildPassword in Windows + Credential Manager (target: CredentialTarget). Installs the CredentialManager + module (CurrentUser scope) automatically if not present. + Equivalent to running manually: + New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' ` + -Password '' -Persist LocalMachine + +.PARAMETER CredentialTarget + Windows Credential Manager target name used when -StoreCredential is set. + Default: BuildVMGuest (matches the target expected by Invoke-CIJob.ps1 and + the other CI scripts that load guest credentials via Get-StoredCredential). + +.EXAMPLE + # Fully automated — VMX path only (power-on + IP detection + snapshot automatic): + .\Prepare-WinBuild2022.ps1 ` + -VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' ` + -BuildPassword 'StrongCIPass!2026' -StoreCredential + + # Skip Windows Update (already applied): + .\Prepare-WinBuild2022.ps1 ` + -VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' ` + -SkipWindowsUpdate -StoreCredential + + # Explicit IP (manual start, no VMware Tools required for IP detection): + .\Prepare-WinBuild2022.ps1 -VMIPAddress 192.168.79.130 ` + -BuildPassword 'StrongCIPass!2026' -StoreCredential + + # Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress: + .\Prepare-WinBuild2022.ps1 ` + -VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' -VMIPAddress 192.168.79.130 ` + -BuildPassword 'StrongCIPass!2026' -StoreCredential +#> +[CmdletBinding()] +param( + # Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot. + # Either -VMXPath or -VMIPAddress (or both) must be supplied. + [string] $VMXPath = '', + + # Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via VMware Tools). + [string] $VMIPAddress = '', + + [string] $AdminUsername = 'Administrator', + + [string] $AdminPassword = '', + + [string] $BuildPassword = '', + + [string] $BuildUsername = 'ci_build', + + [string] $DotNetSdkVersion = '10.0', + + [switch] $SkipWindowsUpdate, + + # Skip disk cleanup inside the VM (cleanmgr + DISM + cache clear) — speeds up re-runs + [switch] $SkipCleanup, + + # Snapshot name (case-sensitive — must match GITEA_CI_SNAPSHOT_NAME in runner/config.yaml) + [string] $SnapshotName = 'BaseClean', + + # Store BuildUsername/BuildPassword in Windows Credential Manager after provisioning + [switch] $StoreCredential, + + # Credential Manager target name (must match what CI scripts use via Get-StoredCredential) + [string] $CredentialTarget = 'BuildVMGuest' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ── Locate vmrun.exe ────────────────────────────────────────────────────────── +$vmrunCandidates = @( + 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe', + 'C:\Program Files\VMware\VMware Workstation\vmrun.exe' +) +$vmrunExe = $vmrunCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $vmrunExe) { + $vmrunFound = Get-Command vmrun -ErrorAction SilentlyContinue + if ($vmrunFound) { $vmrunExe = $vmrunFound.Source } +} +if (-not $vmrunExe) { throw "[Prepare] vmrun.exe not found. Install VMware Workstation or add it to PATH." } + +# ── Validate input: need VMXPath or VMIPAddress ─────────────────────────────── +if ($VMXPath -eq '' -and $VMIPAddress -eq '') { + throw "[Prepare] Provide -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)." +} + +# ── Power on VM and detect IP via VMXPath ──────────────────────────────────── +$vmWasPoweredOn = $false +if ($VMXPath -ne '') { + if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" } + + # Check if already running + $runningList = @(& $vmrunExe list 2>&1 | + Where-Object { $_ -match '\.vmx$' } | ForEach-Object { $_.Trim() }) + + if ($runningList -notcontains $VMXPath) { + Write-Host "[Prepare] VM not running — starting: $VMXPath" + & $vmrunExe start $VMXPath + if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start exited $LASTEXITCODE." } + $vmWasPoweredOn = $true + Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..." + } + else { + Write-Host "[Prepare] VM already running: $VMXPath" + } + + # Auto-detect IP if not supplied + if ($VMIPAddress -eq '') { + Write-Host "[Prepare] Detecting guest IP via VMware Tools (getGuestIPAddress -wait)..." + $detectedIP = (& $vmrunExe getGuestIPAddress $VMXPath -wait 2>&1).Trim() + if ($detectedIP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { + throw "[Prepare] Could not get guest IP. Got: '$detectedIP'. Ensure VMware Tools is installed." + } + $VMIPAddress = $detectedIP + Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green + } +} + +function Assert-Step { + param( + [Parameter(Mandatory)] [string] $StepName, + [Parameter(Mandatory)] [string] $Description, + [Parameter(Mandatory)] [scriptblock] $Test + ) + $ok = $false + try { $ok = [bool](& $Test) } + catch { throw "[Prepare/$StepName] Validation threw on '$Description': $_" } + if (-not $ok) { + throw "[Prepare/$StepName] Validation failed: $Description" + } + Write-Host " [OK] $Description" -ForegroundColor Green +} + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +# Pre-flight validation +Assert-Step 'PreFlight' 'script directory resolved' { Test-Path $scriptDir -PathType Container } +Assert-Step 'PreFlight' 'Setup-WinBuild2022.ps1 present alongside this script' { + Test-Path (Join-Path $scriptDir 'Setup-WinBuild2022.ps1') -PathType Leaf +} +Assert-Step 'PreFlight' "VMIPAddress octets in 0..255" { + $parts = $VMIPAddress.Split('.') | ForEach-Object { [int]$_ } + ($parts.Count -eq 4) -and -not ($parts | Where-Object { $_ -lt 0 -or $_ -gt 255 }) +} + +# WinRM readiness — retry loop (longer timeout if VM was just powered on by this script) +$winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 } +Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5986 (timeout ${winrmTimeoutSec}s)..." +$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec) +$winrmReady = $false +while ([datetime]::UtcNow -lt $winrmDeadline) { + if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 ` + -InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) { + $winrmReady = $true; break + } + Start-Sleep -Seconds 5 + Write-Host " ... waiting for WinRM..." +} +Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady } + +# Prompt for BuildPassword if not supplied — never use a hardcoded default. +if ($BuildPassword -eq '') { + Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:" + $secPwd = Read-Host -Prompt " BuildPassword" -AsSecureString + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd) + $BuildPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) +} + +# ── Build PSCredential ──────────────────────────────────────────────────────── +if ($AdminPassword -eq '') { + Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):" + $credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)" +} +else { + $secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force + $credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd) +} + +$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck + +# ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ────── +# HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts +# needs appending so PS accepts the non-domain VM. Restored in the finally block. +$prevTrustedHosts = $null +try { + $prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value + # Add VM IP to TrustedHosts if not already present + if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") { + $newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" } + Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop + } + Write-Host "[Prepare] Host TrustedHosts configured." + Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" { + $th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value + $th -eq '*' -or $th -like "*$VMIPAddress*" + } +} +catch { + Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)." + Write-Warning "Run once in an elevated PowerShell on the HOST:" + Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force" + Write-Warning "Then re-run this script." + exit 1 +} + + +# ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ── +# New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert. +Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..." +$session = try { + New-PSSession ` + -ComputerName $VMIPAddress ` + -Credential $credential ` + -SessionOption $sessionOptions ` + -UseSSL ` + -Port 5986 ` + -Authentication Basic ` + -ErrorAction Stop +} catch { + Write-Error @" +[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986). + +Ensure: + - The VM is running and on VMnet8 (NAT) with internet access + - WinRM HTTPS is enabled inside the VM — Deploy-WinBuild2022.ps1 post-install.ps1 + must have run successfully (creates self-signed cert + HTTPS listener on 5986) + - Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest) + - The IP is correct (check ipconfig inside the VM) + +Error: $_ +"@ + exit 1 +} + +try { + # ── Copy Setup-WinBuild2022.ps1 into the VM ─────────────────────────────── + $setupScript = Join-Path $scriptDir 'Setup-WinBuild2022.ps1' + $guestScriptPath = 'C:\CI\Setup-WinBuild2022.ps1' + + Write-Host "[Prepare] Ensuring C:\CI exists on guest..." + Invoke-Command -Session $session -ScriptBlock { + if (-not (Test-Path 'C:\CI')) { + New-Item -ItemType Directory -Path 'C:\CI' -Force | Out-Null + } + } + Assert-Step 'GuestPrep' 'C:\CI exists on guest' { + Invoke-Command -Session $session -ScriptBlock { Test-Path 'C:\CI' -PathType Container } + } + + Write-Host "[Prepare] Copying Setup-WinBuild2022.ps1 -> guest $guestScriptPath (UTF-8 BOM)..." + # Do NOT use Copy-Item -ToSession: it binary-copies the file. + # If the source file has no UTF-8 BOM, PowerShell 5.1 on the guest reads it as + # Windows-1252, misinterpreting multi-byte UTF-8 sequences (e.g. em dash U+2014 + # encodes as E2 80 94; byte 0x94 in Win-1252 = curly right-quote, which PS 5.1 + # treats as a string terminator → parse errors throughout the script). + # + # Fix: read content on host as Unicode string, transmit via WinRM (XML/Unicode), + # write on guest with explicit UTF-8 BOM via System.Text.UTF8Encoding($true). + # PS 5.1 detects the BOM and reads the file as UTF-8 correctly. + $scriptContent = [System.IO.File]::ReadAllText($setupScript, [System.Text.Encoding]::UTF8) + Invoke-Command -Session $session -ScriptBlock { + param($content, $path) + $utf8Bom = New-Object System.Text.UTF8Encoding($true) # $true = emit BOM + [System.IO.File]::WriteAllText($path, $content, $utf8Bom) + } -ArgumentList $scriptContent, $guestScriptPath + + # Validation — file present and large enough (BOM transfer changes byte count vs host) + $hostSize = (Get-Item $setupScript).Length + Assert-Step 'GuestPrep' "guest script present at $guestScriptPath" { + Invoke-Command -Session $session -ScriptBlock { param($p) Test-Path $p -PathType Leaf } -ArgumentList $guestScriptPath + } + Assert-Step 'GuestPrep' "guest script size reasonable (host=$hostSize bytes ±6 for BOM)" { + $remoteSize = Invoke-Command -Session $session -ScriptBlock { + param($p) (Get-Item $p -ErrorAction SilentlyContinue).Length + } -ArgumentList $guestScriptPath + # UTF-8 BOM adds 3 bytes; allow small variance + [int64]$remoteSize -ge ([int64]$hostSize - 6) -and [int64]$remoteSize -le ([int64]$hostSize + 6) + } + + # ── Build argument list for Setup-WinBuild2022.ps1 ─────────────────────── + $setupArgs = @{ + BuildPassword = $BuildPassword + BuildUsername = $BuildUsername + DotNetSdkVersion = $DotNetSdkVersion + AdminPassword = $AdminPassword + } + if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true } + if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true } + + # ── Run Setup-WinBuild2022.ps1 inside the VM ────────────────────────────── + # Setup may return exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) when Windows + # Update applied patches that need a reboot. We loop: reboot guest, wait for + # WinRM, reopen session, re-invoke Setup. Steps 1-5 are idempotent (~30 s); + # Step 6 picks up where the previous run left off (no leftover updates → + # exit 0). Hard cap on iterations to avoid an infinite loop. + $maxWuIterations = 10 + $rebootDeadlineMin = 20 + + function Invoke-GuestSetup { + param([Parameter(Mandatory)] $Session, + [Parameter(Mandatory)] [string] $ScriptPath, + [Parameter(Mandatory)] [hashtable] $ScriptArgs) + Invoke-Command -Session $Session -ScriptBlock { + param($scriptPath, $scriptArgs) + Set-ExecutionPolicy Bypass -Scope Process -Force + $exitCode = 0 + try { + & $scriptPath @scriptArgs + $exitCode = $LASTEXITCODE + if ($null -eq $exitCode) { $exitCode = 0 } + } catch { + Write-Error $_ + $exitCode = 1 + } + return $exitCode + } -ArgumentList $ScriptPath, $ScriptArgs + } + + function Wait-GuestWinRMReady { + param([Parameter(Mandatory)] [string] $IP, + [Parameter(Mandatory)] $Cred, + [Parameter(Mandatory)] $SessionOptions, + [int] $TimeoutMin = 20) + $deadline = (Get-Date).AddMinutes($TimeoutMin) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 10 + try { + # Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe. + $probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL ` + -SessionOption $SessionOptions -Credential $Cred ` + -Authentication Basic -ErrorAction Stop + Remove-PSSession $probe -ErrorAction SilentlyContinue + return $true + } catch { } + } + return $false + } + + Write-Host "[Prepare] Running Setup-WinBuild2022.ps1 inside VM (this will take a while)..." + + for ($iter = 1; $iter -le $maxWuIterations; $iter++) { + Write-Host "[Prepare] Setup iteration $iter/$maxWuIterations" + Write-Host "[Prepare] Output from guest:" + Write-Host "------------------------------------------------------------" + + $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" + + if ($result -eq 0) { break } + + if ($result -ne 3010) { + throw "Setup-WinBuild2022.ps1 exited with code $result" + } + + # 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..." + 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)..." + if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential ` + -SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) { + throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot" + } + Write-Host "[Prepare] Guest WinRM ready, reopening session..." + $session = New-PSSession -ComputerName $VMIPAddress -Credential $credential ` + -SessionOption $sessionOptions -UseSSL -Port 5986 ` + -Authentication Basic -ErrorAction Stop + } + + # ── Post-setup remote validation ────────────────────────────────────────── + Write-Host "[Prepare] Running post-setup validation against guest..." + $guestState = Invoke-Command -Session $session -ScriptBlock { + param($BuildUser) + $msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe' + $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH','User') + [PSCustomObject]@{ + WinRMRunning = (Get-Service WinRM -ErrorAction SilentlyContinue).Status -eq 'Running' + UserExists = [bool](Get-LocalUser -Name $BuildUser -ErrorAction SilentlyContinue) + UserIsAdmin = [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUser) + UACDisabled = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA -eq 0 + FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true) + DotNetVersion = (Get-Command dotnet -ErrorAction SilentlyContinue) | ForEach-Object { (& $_.Source --version 2>&1) } + PythonExists = Test-Path 'C:\Python\python.exe' -PathType Leaf + MSBuildExists = Test-Path $msbuildPath -PathType Leaf + CIDirsPresent = (Test-Path 'C:\CI\build') -and (Test-Path 'C:\CI\output') -and (Test-Path 'C:\CI\scripts') + } + } -ArgumentList $BuildUsername + + Assert-Step 'PostSetup' 'guest WinRM Running' { $guestState.WinRMRunning } + Assert-Step 'PostSetup' "guest user '$BuildUsername' exists" { $guestState.UserExists } + Assert-Step 'PostSetup' "guest user '$BuildUsername' is admin" { $guestState.UserIsAdmin } + Assert-Step 'PostSetup' 'guest UAC disabled' { $guestState.UACDisabled } + Assert-Step 'PostSetup' 'guest firewall disabled' { $guestState.FirewallOff } + Assert-Step 'PostSetup' 'guest .NET SDK installed' { [bool]$guestState.DotNetVersion } + Assert-Step 'PostSetup' 'guest Python installed' { $guestState.PythonExists } + Assert-Step 'PostSetup' 'guest MSBuild present' { $guestState.MSBuildExists } + Assert-Step 'PostSetup' 'guest C:\CI\{build,output,scripts} present' { $guestState.CIDirsPresent } + + Write-Host "[Prepare] All post-setup validations passed (.NET $($guestState.DotNetVersion))." -ForegroundColor Green + + # ── Store credentials in Windows Credential Manager (optional) ──────────── + if ($StoreCredential) { + Write-Host "[Prepare] Storing CI credentials in Windows Credential Manager (target: '$CredentialTarget')..." + if (-not (Get-Module -ListAvailable -Name CredentialManager)) { + Write-Host "[Prepare] CredentialManager module not found — installing (CurrentUser scope)..." + Install-Module -Name CredentialManager -Scope CurrentUser -Force -ErrorAction Stop + } + Import-Module CredentialManager -ErrorAction Stop + New-StoredCredential ` + -Target $CredentialTarget ` + -UserName $BuildUsername ` + -Password $BuildPassword ` + -Persist LocalMachine ` + -ErrorAction Stop | Out-Null + Write-Host "[Prepare] Credentials stored: target='$CredentialTarget' user='$BuildUsername'." -ForegroundColor Green + } + + Write-Host "[Prepare] All validations passed." -ForegroundColor Green + + # ── Shutdown + snapshot prompt ───────────────────────────────────────────── + # vmrun.exe already located at script start ($vmrunExe) + + # Auto-discover VMX path via 'vmrun list' while VM is still running (if not known) + if ($VMXPath -eq '') { + Write-Host "[Prepare] Discovering VMX path from running VMs..." + $runningVMs = @(& $vmrunExe list 2>&1 | + Where-Object { $_ -match '\.vmx$' } | + ForEach-Object { $_.Trim() }) + + if ($runningVMs.Count -eq 0) { + throw "[Prepare] No running VMs found via 'vmrun list'. Is the template VM still up?" + } + elseif ($runningVMs.Count -eq 1) { + $VMXPath = $runningVMs[0] + Write-Host "[Prepare] VMX auto-detected: $VMXPath" -ForegroundColor Green + } + else { + # Multiple VMs — prefer one under \CI\Templates\ + $ciVMs = @($runningVMs | Where-Object { $_ -like '*\CI\Templates\*' }) + if ($ciVMs.Count -eq 1) { + $VMXPath = $ciVMs[0] + Write-Host "[Prepare] VMX auto-detected (CI template): $VMXPath" -ForegroundColor Green + } + else { + Write-Host "[Prepare] Multiple VMs running — select one:" + for ($i = 0; $i -lt $runningVMs.Count; $i++) { + Write-Host " [$($i+1)] $($runningVMs[$i])" + } + $idx = [int](Read-Host "[Prepare] Enter number") - 1 + if ($idx -lt 0 -or $idx -ge $runningVMs.Count) { throw "[Prepare] Invalid selection." } + $VMXPath = $runningVMs[$idx] + } + } + } + + if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found on disk: '$VMXPath'" } + + # Prompt + Write-Host "------------------------------------------------------------" + Write-Host "" + $choice = Read-Host "[Prepare] Shut down VM and create snapshot '$SnapshotName'? [Y/N]" + if ($choice -match '^[Yy]') { + + # Send graceful shutdown + Write-Host "[Prepare] Sending graceful shutdown to VM..." + try { + Invoke-Command -Session $session -ScriptBlock { Stop-Computer -Force } -ErrorAction Stop + Write-Host "[Prepare] Shutdown command sent." -ForegroundColor Green + } + catch { + Write-Warning "[Prepare] Shutdown command failed (VM may have already shut down): $_" + } + + # Close session — VM is shutting down, no further WinRM needed + Remove-PSSession $session -ErrorAction SilentlyContinue + + # Wait for VM to power off (poll vmrun list) + Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..." + $shutdownDeadline = [datetime]::UtcNow.AddSeconds(120) + $poweredOff = $false + while ([datetime]::UtcNow -lt $shutdownDeadline) { + $runningList = & $vmrunExe list 2>&1 | Out-String + if ($runningList -notmatch [regex]::Escape($VMXPath)) { $poweredOff = $true; break } + Start-Sleep -Seconds 5 + Write-Host " ... waiting for shutdown..." + } + if (-not $poweredOff) { throw "[Prepare] VM did not power off within 120 s. Check VMware Workstation." } + + # Check for existing snapshot with same name + $existingSnaps = & $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String + $doCreate = $true + if ($existingSnaps -match [regex]::Escape($SnapshotName)) { + Write-Warning "[Prepare] Snapshot '$SnapshotName' already exists." + $delChoice = Read-Host "[Prepare] Delete existing snapshot and recreate? [Y/N]" + if ($delChoice -match '^[Yy]') { + Write-Host "[Prepare] Deleting existing snapshot '$SnapshotName'..." + & $vmrunExe deleteSnapshot $VMXPath $SnapshotName + if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun deleteSnapshot exited $LASTEXITCODE." } + Write-Host "[Prepare] Existing snapshot deleted." -ForegroundColor Green + } + else { + Write-Host "[Prepare] Keeping existing snapshot — skipping creation." -ForegroundColor Yellow + $doCreate = $false + } + } + + # Create snapshot + if ($doCreate) { + Write-Host "[Prepare] Creating snapshot '$SnapshotName'..." -ForegroundColor Green + & $vmrunExe -T ws snapshot $VMXPath $SnapshotName + if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun snapshot exited $LASTEXITCODE." } + Write-Host "[Prepare] Snapshot '$SnapshotName' created successfully." -ForegroundColor Green + } + Write-Host "" + Write-Host "============================================================" -ForegroundColor Green + Write-Host " Template VM provisioning complete!" -ForegroundColor Green + Write-Host "============================================================" -ForegroundColor Green + if ($StoreCredential) { + Write-Host " [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green + } + Write-Host "" + } + else { + Write-Host "" + Write-Host "============================================================" -ForegroundColor Green + Write-Host " Template VM provisioning complete!" -ForegroundColor Green + Write-Host "============================================================" -ForegroundColor Green + Write-Host "" + Write-Host " MANUAL STEPS REQUIRED:" + Write-Host "" + Write-Host " 1. Shut down the VM: Start -> Shut down" + Write-Host " (VM stays on VMnet8 NAT for internet access during builds)" + Write-Host "" + Write-Host " 2. Take snapshot in VMware Workstation:" + Write-Host " VM -> Snapshot -> Take Snapshot" + Write-Host " Name it EXACTLY: $SnapshotName" + Write-Host "" + if ($StoreCredential) { + Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green + } + else { + Write-Host " 3. Store CI credentials on this HOST (run in elevated PowerShell):" + Write-Host " .\Prepare-WinBuild2022.ps1 -VMIPAddress $VMIPAddress -StoreCredential" + Write-Host " (or manually:)" + Write-Host " New-StoredCredential -Target '$CredentialTarget' ``" + Write-Host " -UserName '$BuildUsername' -Password '' ``" + Write-Host " -Persist LocalMachine" + } + Write-Host "" + } +} +finally { + Remove-PSSession $session -ErrorAction SilentlyContinue + + # Restore host TrustedHosts to its original value + if ($null -ne $prevTrustedHosts) { + Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue + } + Write-Host "[Prepare] Host TrustedHosts restored." +} diff --git a/template/Setup-WinBuild2022.ps1 b/template/Setup-WinBuild2022.ps1 new file mode 100644 index 0000000..328ca64 --- /dev/null +++ b/template/Setup-WinBuild2022.ps1 @@ -0,0 +1,1139 @@ +#Requires -RunAsAdministrator +#Requires -Version 5.1 +<# +.SYNOPSIS + Provisions a Windows VM as a CI build template (run INSIDE the VM once). + +.DESCRIPTION + This script is run ONE TIME inside the template VM before taking the + "BaseClean" snapshot. After the snapshot is taken, never modify the VM. + + Prerequisite: Deploy-WinBuild2022.ps1 must have completed first (unattended OS + install + post-install.ps1 hardening). Steps 1, 3, 4b, 5b, 5c are validation-only + — they assert that Deploy already set the expected state rather than re-applying it. + Steps 4, 5, 6/6b, 7-10 perform actual CI customisation. + + Steps and Assert-Step validation: + Step 1 — Firewall: validation only — Deploy disabled all profiles. + Validation: Domain/Private/Public all Enabled=False + Step 2 — Defender: validation only — Deploy set DisableAntiSpyware=1 GPO + RTP off. + Validation: GPO DisableAntiSpyware=1 + Step 3 — WinRM: validation only — Deploy ran Enable-PSRemoting, Basic auth over HTTPS, + AllowUnencrypted=false (HTTPS-only), MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=25. + Validation: service Running+Automatic, AllowUnencrypted=false, Auth/Basic, memory + Step 3b — Windows KMS activation via slmgr /skms + /ato (best-effort, non-fatal). + Validation: LicenseStatus=1 check (warning-only on failure) + Step 4 — Local build user account ($BuildUsername, PasswordNeverExpires, Administrators) + Validation: user exists, enabled, PasswordNeverExpires, admin membership + Step 4b — UAC: validation only — Deploy set EnableLUA=0, LocalAccountTokenFilterPolicy=1. + Validation: both registry values verified + Step 5 — CI working directories: C:\CI\build, C:\CI\output, C:\CI\scripts + Must run before Windows Update — WU worker writes temp files to C:\CI + Validation: each path exists as Container + Step 5b — Explorer LaunchTo=1: validation only — Deploy set HKCU + Default hive. + Validation: HKCU LaunchTo=1 + Step 5c — CI UX hardening: validation only — Deploy set lock screen, Server Manager + (policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System + + Winlogon), OOBE/telemetry suppressed. + Validation: NoLockScreen, SM policy, SM task, DisableCAD, OOBE, telemetry + Step 6 — Windows Update via Scheduled Task as SYSTEM (avoids WinRM token limit). + Clears Deploy's GPO locks (Step A), starts services (Step D), runs COM API, + reports result code. Skipped with -SkipWindowsUpdate. + Validation: result code 0/2/3, no reboot required (else exit 3010) + Step 6b — Windows Update permanently disabled (post-update lockdown — always runs). + wuauserv + UsoSvc set to Disabled; GPO keys NoAutoUpdate=1, + DisableWindowsUpdateAccess=1; WU scheduled tasks disabled. + Snapshot captures WU permanently off — linked clones never auto-update. + Validation: wuauserv StartType=Disabled, GPO keys present + Step 7 — .NET SDK (channel $DotNetSdkVersion) via dotnet-install.ps1 + Validation: dotnet resolvable, version channel match, machine PATH + Step 8 — Python $PythonVersion (all users, C:\Python, PrependPath) + Validation: binary present, exact version match, PATH + Step 9 — Visual Studio Build Tools 2026 via Scheduled Task as SYSTEM + Validation: MSBuild.exe exists, executes, PATH, VC dir present + Step 10 — Toolchain cross-check (dotnet, python, msbuild) + Throws on any missing tool + Cleanup — Disk cleanup: cleanmgr, SoftwareDistribution, CBS, TEMP, Prefetch, DISM + wuauserv already Disabled (Step 6b) — not restarted after cache clear + Validation: wuauserv StartType Disabled + Final — Pre-snapshot gate: 7 cross-cutting checks across all steps + Throws if any check fails — prevents snapshot of broken state + + Step ordering rationale: + Firewall (Step 1) — validates Deploy disabled all profiles; assertion before WinRM/WU + Defender (Step 2) — validates Deploy disabled RTP; assertion before tool install + WinRM (Step 3) — validates Deploy configured WinRM; assertion before remote steps + User (Step 4) — creates ci_build; must exist before UAC and dirs assertions + UAC (Step 4b) — validates Deploy disabled UAC (elevation-free installs in 7-9) + CI dirs (Step 5) — C:\CI must exist before WU worker writes temp files there + UI tweaks (5b/5c) — validates Deploy UX settings; fast assertions before slow WU + WU (Step 6) — all prereqs validated; WU runs with Firewall+Defender already off + WU disable (Step 6b) — immediately after WU; snapshot captures WU permanently off + Toolchain (7-9) — .NET/Python/VS last; WU done, no scan on installer payloads + + NOTE: Git is NOT installed by default. See §6.6 (Tier-1 Toolchain) in TODO.md + for the planned addition. The host clones and copies source via WinRM until then. + + ╔══════════════════════════════════════════════════════════════════════╗ + ║ NETWORK REQUIREMENT DURING SETUP ║ + ║ The VM must have internet access while this script runs so it can ║ + ║ download Windows Updates and tool installers. The VM NIC must be ║ + ║ set to VMnet8 (NAT) — keep it on NAT permanently (builds use NAT ║ + ║ for pip/nuget downloads at runtime). ║ + ╚══════════════════════════════════════════════════════════════════════╝ + + After this script completes (exit 0, all Assert-Step checks passed): + 1. Shut down the VM (Start → Shut down) + The VM stays on VMnet8 (NAT) — internet access is required for builds. + 2. In VMware Workstation: VM → Snapshot → Take Snapshot + Name it exactly: BaseClean + 3. Power off the VM and leave it powered off permanently + +.PARAMETER BuildUsername + Local username for the CI build account. Default: ci_build. + +.PARAMETER BuildPassword + Password for the build account. Mandatory — supplied by Prepare-WinBuild2022.ps1 + (which prompts interactively). Store the same value in Windows Credential Manager + on the HOST as target "BuildVMGuest". + +.PARAMETER DotNetSdkVersion + .NET SDK channel to install. Default: 10.0 (matches host SDK). + +.PARAMETER PythonVersion + Exact Python release to install (x.y.z, must exist on python.org). Default: 3.13.3. + +.PARAMETER SkipWindowsUpdate + Skip the Windows Update step (useful if updates were already applied). + +.NOTES + Invoke via Prepare-WinBuild2022.ps1 from the HOST (recommended), or + run manually from an elevated PowerShell session INSIDE the VM: + Set-ExecutionPolicy Bypass -Scope Process -Force + .\Setup-WinBuild2022.ps1 -BuildPassword 'YourPassword' +#> +[CmdletBinding()] +param( + # Local username for the CI build account inside the VM + [string] $BuildUsername = 'ci_build', + + # Password for the build account. + # Must be supplied by the caller (Prepare-WinBuild2022.ps1 prompts interactively). + # Store the same password in Windows Credential Manager on the HOST as target "BuildVMGuest". + [Parameter(Mandatory)] + [string] $BuildPassword, + + # .NET SDK channel to install (should match the host SDK version) + [string] $DotNetSdkVersion = '10.0', + + # Python version to install (x.y.z — must match an exact release on python.org) + [string] $PythonVersion = '3.13.3', + + # Skip Windows Update (useful when updates already applied or no internet) + [switch] $SkipWindowsUpdate, + + # Skip disk cleanup step (cleanmgr + DISM + cache clear) — speeds up re-runs during dev/debug + [switch] $SkipCleanup, + + # Administrator password — used only to write DefaultPassword for autologin. + # Optional: if empty, DefaultPassword registry value is left as-is (Deploy may have set it). + [string] $AdminPassword = '' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Message) + Write-Host "`n=== $Message ===" -ForegroundColor Cyan +} + +function Assert-Step { + param( + [Parameter(Mandatory)] [string] $StepName, + [Parameter(Mandatory)] [string] $Description, + [Parameter(Mandatory)] [scriptblock] $Test + ) + $ok = $false + try { $ok = [bool](& $Test) } + catch { throw "[$StepName] Validation threw on '$Description': $_" } + if (-not $ok) { + throw "[$StepName] Validation failed: $Description" + } + Write-Host " [OK] $Description" -ForegroundColor Green +} + +function Assert-Hash { + # Verifies SHA256 of a downloaded file against a pinned expected value. + # If $Expected is empty the check is skipped with a warning (unpinned installer). + # To pin: download the file, run (Get-FileHash -Algorithm SHA256).Hash, fill in below. + param( + [Parameter(Mandatory)] [string] $FilePath, + [Parameter(Mandatory)] [string] $Label, + [string] $Expected = '' + ) + if (-not $Expected) { + Write-Host " [WARN] Hash not pinned for $Label — set `$script:Hashes to enforce (see §1.3)" -ForegroundColor Yellow + return + } + $actual = (Get-FileHash $FilePath -Algorithm SHA256).Hash.ToUpper() + if ($actual -ne $Expected.ToUpper()) { + throw "Hash mismatch for ${Label}:`n expected: $($Expected.ToUpper())`n actual: $actual`nPossible MITM or wrong installer version — do NOT proceed." + } + Write-Host " [OK] SHA256 verified: $Label" -ForegroundColor Green +} + +# ── Pinned SHA256 hashes for downloaded installers (§1.3) ───────────────────── +# Fill in the hash values below. Get them by: +# (Invoke-WebRequest '' -UseBasicParsing -OutFile 'tmp.bin'; (Get-FileHash 'tmp.bin' -Algorithm SHA256).Hash) +# Update when the corresponding version param changes. Leave '' to skip (warns at runtime). +$script:Hashes = @{ + # python--amd64.exe from https://www.python.org/downloads/release/python-XYZ/ + # Update $script:Hashes['Python'] when $PythonVersion changes. + 'Python' = '' + + # dotnet-install.ps1 from https://dot.net/v1/dotnet-install.ps1 + # Changes with each .NET SDK release cycle — verify after any dotnet version bump. + 'DotNetInstallScript' = '' + + # vs_buildtools.exe bootstrapper from aka.ms/vs/stable/vs_buildtools.exe + # Stable within a VS release cycle — update when VS major version changes. + 'VSBuildToolsBootstrapper' = '' +} + +# ── Pre-flight: remove temp files from any previous partial run ─────────────── +# These files are normally deleted at the end of each step that creates them. +# A previous interrupted run may have left them behind — remove before starting. +$knownTempFiles = @( + 'C:\CI\wu_worker.ps1', 'C:\CI\wu_result.txt', 'C:\CI\wu_reboot.txt', 'C:\CI\wu_log.txt', + 'C:\CI\vs_worker.ps1', 'C:\CI\vs_result.txt', 'C:\CI\vs_BuildTools.exe', + 'C:\CI\python_installer.exe' +) +foreach ($f in $knownTempFiles) { + if (Test-Path $f) { + Remove-Item $f -Force -ErrorAction SilentlyContinue + Write-Host "[PreFlight] Removed leftover temp file: $f" + } +} + +# ── Step 1: Firewall (validation only — disabled by Deploy-WinBuild2022) ────── +Write-Step "Firewall state (validation only — disabled at deploy time)" + +# Deploy-WinBuild2022.ps1 post-install.ps1 called Set-NetFirewallProfile -Enabled False +# on all profiles. Validated here before WinRM and WU assertions proceed. + +foreach ($p in 'Domain','Private','Public') { + Assert-Step 'Firewall' "$p profile disabled" { + (Get-NetFirewallProfile -Profile $p -ErrorAction Stop).Enabled -eq $false + } +} + +# ── Step 2: Defender — already disabled by Deploy-WinBuild2022 ─────────────── +# Deploy-WinBuild2022.ps1 set RTP off + DisableAntiSpyware=1 GPO during the +# unattended install. With Defender fully off, exclusion paths are moot — the +# scanner that would respect them isn't running. Keep a sanity check on the +# tamper-protect GPO key so we throw early if someone re-enabled Defender. +Write-Step "Defender state (validation only — disabled at deploy time)" +Assert-Step 'Defender' 'GPO DisableAntiSpyware=1 (set by Deploy-WinBuild2022)' { + $key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' + (Test-Path $key) -and + ((Get-ItemProperty -Path $key -Name DisableAntiSpyware -ErrorAction SilentlyContinue).DisableAntiSpyware -eq 1) +} + +# ── Step 3: WinRM (validation only — configured by Deploy-WinBuild2022) ─────── +Write-Step "WinRM state (validation only — configured at deploy time)" + +# Deploy-WinBuild2022.ps1 post-install.ps1 ran Enable-PSRemoting, set Basic auth over HTTPS, +# AllowUnencrypted=false (HTTPS/5986 only), TrustedHosts=*, MaxMemory=2048MB, IdleTimeout=2h, +# MaxProcesses=25, StartupType=Automatic. Validated here before the build user and toolchain steps. + +Assert-Step 'WinRM' 'service Running' { + (Get-Service WinRM -ErrorAction Stop).Status -eq 'Running' +} +Assert-Step 'WinRM' 'service StartType Automatic' { + (Get-Service WinRM -ErrorAction Stop).StartType -eq 'Automatic' +} +Assert-Step 'WinRM' 'AllowUnencrypted=false (HTTPS-only)' { + (Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'false' +} +Assert-Step 'WinRM' 'Auth/Basic=true' { + (Get-Item WSMan:\localhost\Service\Auth\Basic).Value -eq 'true' +} +Assert-Step 'WinRM' 'MaxMemoryPerShellMB >= 2048' { + [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB).Value -ge 2048 +} + +# ── Step 3b: Windows KMS Activation ───────────────────────────────────────── +# Must run after network is confirmed (Step 3) and before Windows Update (Step 6) +# so activation completes before WU queries the license state. +# slmgr.vbs is a VBScript — invoke via cscript //NoLogo to get stdout output +# instead of a dialog box (dialogs are invisible / hang in WinRM sessions). +# Best-effort: activation failure does not abort Setup — CI builds work without +# activation (all features used here are available in the grace period). +Write-Step "Windows KMS activation" + +$cscript = "$env:SystemRoot\System32\cscript.exe" +$slmgr = "$env:SystemRoot\System32\slmgr.vbs" + +Write-Host "Setting KMS server..." +& $cscript //NoLogo $slmgr /skms kms8.msguides.com 2>&1 | Write-Host + +Write-Host "Requesting activation..." +& $cscript //NoLogo $slmgr /ato 2>&1 | Write-Host + +# Verify activation status (LicenseStatus 1 = Licensed) +$licOk = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%'" -ErrorAction SilentlyContinue | + Where-Object { $_.LicenseStatus -eq 1 } +if ($licOk) { + Write-Host " [OK] Windows activated (LicenseStatus=1)." -ForegroundColor Green +} else { + Write-Warning " [WARN] Windows not activated — /ato may have failed or KMS server unreachable. CI builds will work in the grace period." +} + +# ── Step 4: Build user account ─────────────────────────────────────────────── +Write-Step "Creating build user account: $BuildUsername" + +$existingUser = Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue +if ($existingUser) { + Write-Host "User '$BuildUsername' already exists — skipping creation." +} +else { + # Convert to SecureString locally (inside the VM session) to avoid exposing + # the password as a process argument visible in audit log event 4688. + $secPwd = ConvertTo-SecureString $BuildPassword -AsPlainText -Force + try { + New-LocalUser -Name $BuildUsername -Password $secPwd ` + -PasswordNeverExpires -Description 'CI build account' ` + -ErrorAction Stop | Out-Null + } + catch { + throw "Failed to create user '$BuildUsername': $_" + } + Write-Host "User '$BuildUsername' created." +} + +# Always ensure password policy and Administrators membership (idempotent) +& net user $BuildUsername /passwordchg:no | Out-Null +Set-LocalUser -Name $BuildUsername -PasswordNeverExpires $true + +$isMember = & net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername +if (-not $isMember) { + & net localgroup Administrators $BuildUsername /add | Out-Null + Write-Host "User '$BuildUsername' added to Administrators." +} +else { + Write-Host "User '$BuildUsername' already in Administrators." +} + +# Validation +Assert-Step 'User' "user '$BuildUsername' exists" { + [bool](Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue) +} +Assert-Step 'User' "user '$BuildUsername' enabled" { + (Get-LocalUser -Name $BuildUsername -ErrorAction Stop).Enabled +} +Assert-Step 'User' "user '$BuildUsername' password never expires" { + # Property name varies across LocalAccounts module versions: + # - Newer (Server 2022+, some SKUs): bool 'PasswordNeverExpires' + # - Older / WS2022 PS 5.1: nullable datetime 'PasswordExpires' ($null = never) + $u = Get-LocalUser -Name $BuildUsername -ErrorAction Stop + $hasFlag = $u.PSObject.Properties.Name -contains 'PasswordNeverExpires' + if ($hasFlag) { [bool]$u.PasswordNeverExpires } else { $null -eq $u.PasswordExpires } +} +Assert-Step 'User' "user '$BuildUsername' member of Administrators" { + [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) +} + +# ── Step 4b: UAC (validation only — disabled by Deploy-WinBuild2022) ───────── +Write-Step "UAC state (validation only — disabled at deploy time)" + +# Deploy-WinBuild2022.ps1 post-install.ps1 set EnableLUA=0 (no prompt for admin +# processes) and LocalAccountTokenFilterPolicy=1 (WinRM remote connections get full +# elevated token, required for runProgramInGuest and remote admin commands). + +$uacPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' +Assert-Step 'UAC' 'EnableLUA=0' { + (Get-ItemProperty -Path $uacPolicyKey -Name EnableLUA -ErrorAction Stop).EnableLUA -eq 0 +} +Assert-Step 'UAC' 'LocalAccountTokenFilterPolicy=1' { + (Get-ItemProperty -Path $uacPolicyKey -Name LocalAccountTokenFilterPolicy -ErrorAction Stop).LocalAccountTokenFilterPolicy -eq 1 +} + +# ── Step 5: CI working directories ─────────────────────────────────────────── +Write-Step "Creating CI working directories" + +# Must run before Windows Update (Step 6): the WU worker script writes temp files +# to C:\CI\ (wu_worker.ps1, wu_result.txt, wu_reboot.txt, wu_log.txt). +$ciDirs = @('C:\CI\build', 'C:\CI\output', 'C:\CI\scripts') +foreach ($dir in $ciDirs) { + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + Write-Host "Created: $dir" + } +} + +# Validation +foreach ($dir in $ciDirs) { + Assert-Step 'CIDirs' "$dir exists (Container)" { + Test-Path -Path $dir -PathType Container + } +} + +# ── Step 5b: Explorer settings (validation only — configured by Deploy-WinBuild2022) ── +Write-Step "Explorer defaults (validation only — configured at deploy time)" + +# Deploy-WinBuild2022.ps1 post-install.ps1 set LaunchTo=1 for HKCU (Administrator) +# and the Default User hive (so ci_build and any new user inherits at first logon). + +$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' +Assert-Step 'Explorer' "LaunchTo=1 (HKCU current user)" { + (Get-ItemProperty -Path $explorerAdvKey -Name LaunchTo -ErrorAction Stop).LaunchTo -eq 1 +} + +# ── Step 5c: CI UX hardening (validation only — configured by Deploy-WinBuild2022) ─ +Write-Step "CI UX hardening state (validation only — configured at deploy time)" + +# Deploy-WinBuild2022.ps1 post-install.ps1 set: lock screen (NoLockScreen=1), Server +# Manager (policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System + +# Winlogon), OOBE DisablePrivacyExperience (policy + non-policy), AllowTelemetry=0. + +$personPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' +$smPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' +$smHKLMKey = 'HKLM:\SOFTWARE\Microsoft\ServerManager' +$smUserKey = 'HKCU:\SOFTWARE\Microsoft\ServerManager' +$systemPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' +$oobePolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' +$dcKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' + +Assert-Step 'CIUX' 'lock screen disabled (NoLockScreen=1)' { + (Get-ItemProperty -Path $personPolicyKey -Name NoLockScreen -ErrorAction Stop).NoLockScreen -eq 1 +} +Assert-Step 'CIUX' 'Server Manager policy DoNotOpenAtLogon=1' { + (Get-ItemProperty -Path $smPolicyKey -Name DoNotOpenAtLogon -ErrorAction Stop).DoNotOpenAtLogon -eq 1 +} +Assert-Step 'CIUX' 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { + (Get-ItemProperty -Path $smHKLMKey -Name DoNotOpenServerManagerAtLogon -ErrorAction Stop).DoNotOpenServerManagerAtLogon -eq 1 +} +Assert-Step 'CIUX' 'Server Manager scheduled task disabled' { + $t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' ` + -TaskName 'ServerManager' -ErrorAction SilentlyContinue + (-not $t) -or ($t.State -eq 'Disabled') +} +Assert-Step 'CIUX' 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1 (Administrator)' { + (Get-ItemProperty -Path $smUserKey -Name DoNotOpenServerManagerAtLogon -ErrorAction Stop).DoNotOpenServerManagerAtLogon -eq 1 +} +Assert-Step 'CIUX' 'DisableCAD=1 in Policies\System' { + (Get-ItemProperty -Path $systemPolicyKey -Name DisableCAD -ErrorAction Stop).DisableCAD -eq 1 +} +Assert-Step 'CIUX' 'OOBE policy DisablePrivacyExperience=1' { + (Get-ItemProperty -Path $oobePolicyKey -Name DisablePrivacyExperience -ErrorAction Stop).DisablePrivacyExperience -eq 1 +} +Assert-Step 'CIUX' 'DataCollection AllowTelemetry=0' { + (Get-ItemProperty -Path $dcKey -Name AllowTelemetry -ErrorAction Stop).AllowTelemetry -eq 0 +} +# AutoAdminLogon: operativo — Deploy sets this, but Windows Server 2022 OOBE/first-boot +# tasks can reset AutoAdminLogon to 0 after the initial login. Re-affirm without touching +# DefaultPassword (already written by Deploy's post-install.ps1 and still present). +$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' +$wlNow = Get-ItemProperty -Path $wlKey -ErrorAction SilentlyContinue +if ($wlNow.AutoAdminLogon -ne '1' -or $wlNow.DefaultUserName -ne 'Administrator') { + Set-ItemProperty -Path $wlKey -Name AutoAdminLogon -Value '1' -Type String -Force + Set-ItemProperty -Path $wlKey -Name DefaultUserName -Value 'Administrator' -Type String -Force + Set-ItemProperty -Path $wlKey -Name DefaultDomainName -Value '.' -Type String -Force +} +# DefaultPassword written unconditionally when available — previous run may have set +# AutoAdminLogon=1 without knowing the password (AdminPassword was added later). +if ($AdminPassword -ne '') { + Set-ItemProperty -Path $wlKey -Name DefaultPassword -Value $AdminPassword -Type String -Force +} +Assert-Step 'CIUX' 'autologin Administrator enabled (AutoAdminLogon=1)' { + $wl = Get-ItemProperty -Path $wlKey -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. +# C:\CI exists (Step 5): worker temp files write successfully. +# NOTE: The Windows Update COM API (Microsoft.Update.Session) raises E_ACCESSDENIED +# when called from a WinRM/network-logon session. We work around this by running +# the actual download+install inside a Scheduled Task (SYSTEM account, full token). +if ($SkipWindowsUpdate) { + Write-Host "[Setup] Skipping Windows Update (SkipWindowsUpdate switch set)." +} +else { + Write-Step "Running Windows Update via Scheduled Task (avoids WinRM token restrictions)" + + # --- worker script that runs as SYSTEM inside the scheduled task --- + $wuWorkerPath = 'C:\CI\wu_worker.ps1' + $wuResultPath = 'C:\CI\wu_result.txt' + $wuRebootPath = 'C:\CI\wu_reboot.txt' + $wuLogPath = 'C:\CI\wu_log.txt' + + $wuWorker = @' +$ErrorActionPreference = 'Stop' +try { + $s = New-Object -ComObject Microsoft.Update.Session + $q = $s.CreateUpdateSearcher() + $q.Online = $true # force live query — skip stale client cache + $q.ServerSelection = 2 # 2=ssWindowsUpdate — bypass WSUS, query WU servers directly + $found = $q.Search("IsInstalled=0 and Type='Software'") + $count = $found.Updates.Count + if ($count -eq 0) { + "0" | Set-Content C:\CI\wu_result.txt + "False" | Set-Content C:\CI\wu_reboot.txt + "No updates needed (0 updates found by COM API)." | Set-Content C:\CI\wu_log.txt + } else { + $names = ($found.Updates | ForEach-Object { " - $($_.Title)" }) -join "`n" + "Found $count update(s):`n$names" | Set-Content C:\CI\wu_log.txt + "Downloading $count update(s)..." | Add-Content C:\CI\wu_log.txt + $dl = $s.CreateUpdateDownloader() + $dl.Updates = $found.Updates + $dl.Download() | Out-Null + "Installing $count update(s)..." | Add-Content C:\CI\wu_log.txt + $inst = $s.CreateUpdateInstaller() + $inst.Updates = $found.Updates + $r = $inst.Install() + [string]$r.ResultCode | Set-Content C:\CI\wu_result.txt + [string]$r.RebootRequired | Set-Content C:\CI\wu_reboot.txt + "Done. ResultCode=$($r.ResultCode) RebootRequired=$($r.RebootRequired)" | Add-Content C:\CI\wu_log.txt + } +} catch { + "ERROR: $_" | Set-Content C:\CI\wu_log.txt + "99" | Set-Content C:\CI\wu_result.txt + "False" | Set-Content C:\CI\wu_reboot.txt +} +'@ + $wuWorker | Set-Content -Path $wuWorkerPath -Encoding UTF8 + + # Re-enable WU fully before launching the COM API worker. + # A previous run of this script (Step 6b) leaves: wuauserv=Disabled, UsoSvc=Disabled, + # DisableWindowsUpdateAccess=1, stale DataStore metadata. + # Microsoft.Update.Session.Search() silently returns 0 results if any of these are set. + $wuPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' + $wuPolicyAUKey = "$wuPolicyKey\AU" + + # Step A: clear GPO policy blocks + Remove-ItemProperty -Path $wuPolicyKey -Name 'DisableWindowsUpdateAccess' -ErrorAction SilentlyContinue + Remove-ItemProperty -Path $wuPolicyAUKey -Name 'NoAutoUpdate' -ErrorAction SilentlyContinue + Remove-ItemProperty -Path $wuPolicyAUKey -Name 'AUOptions' -ErrorAction SilentlyContinue + + # Step B: stop WU-adjacent services cleanly before resetting state + foreach ($svc in 'wuauserv', 'UsoSvc', 'bits') { + Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue + } + + # Step C: delete WU client database — forces fresh online detection on next start. + # Without this, the COM API reuses stale cached metadata from the previous run + # and reports 0 available updates even when updates exist. + Remove-Item 'C:\Windows\SoftwareDistribution\DataStore\*' -Recurse -Force -ErrorAction SilentlyContinue + + # Step D: re-enable and start all services the COM API depends on + foreach ($svc in 'cryptsvc', 'bits', 'wuauserv', 'UsoSvc') { + Set-Service -Name $svc -StartupType Manual -ErrorAction SilentlyContinue + Start-Service -Name $svc -ErrorAction SilentlyContinue + } + + # Step E: wait for WU service to fully initialize and build fresh DataStore + Start-Sleep -Seconds 20 + Write-Host "Windows Update services started. DataStore reset. GPO blocks cleared." + + # Remove any leftover result files from a previous run + Remove-Item $wuResultPath, $wuRebootPath -ErrorAction SilentlyContinue + + # Register scheduled task running as SYSTEM + $taskName = 'CI-WindowsUpdate' + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$wuWorkerPath`"" + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest + Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null + + Write-Host "Scheduled task registered. Starting Windows Update..." + Start-ScheduledTask -TaskName $taskName + + # Poll until the task finishes (result file written = task complete) + $timeout = [datetime]::UtcNow.AddMinutes(60) + $dotCount = 0 + while (-not (Test-Path $wuResultPath)) { + if ([datetime]::UtcNow -gt $timeout) { + throw "Windows Update timed out after 60 minutes." + } + Start-Sleep -Seconds 15 + $dotCount++ + if ($dotCount % 4 -eq 0) { Write-Host " ... still running ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" } + } + + # Read results + $resultCode = [int](Get-Content $wuResultPath -Raw).Trim() + $rebootNeeded = (Get-Content $wuRebootPath -Raw).Trim() -eq 'True' + $log = Get-Content $wuLogPath -Raw -ErrorAction SilentlyContinue + + Write-Host "Windows Update log:" + Write-Host $log + + # Clean up + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-Item $wuWorkerPath, $wuResultPath, $wuRebootPath, $wuLogPath -ErrorAction SilentlyContinue + + if ($resultCode -eq 99) { + throw "Windows Update worker script failed. See log above." + } + if ($resultCode -notin @(0, 2, 3)) { + Write-Warning "Windows Update returned unexpected result code: $resultCode" + } + else { + Write-Host "Windows Update completed (ResultCode=$resultCode)." + } + + if ($rebootNeeded) { + Write-Warning "*** REBOOT REQUIRED to finish installing updates. ***" + Write-Warning " Restart the VM, then re-run this script with: -SkipWindowsUpdate" + exit 3010 + } +} + +# ── Step 6b: Disable Windows Update permanently (post-update lockdown) ─────── +Write-Step "Disabling Windows Update permanently (post-update snapshot lockdown)" + +# WU has completed (or was skipped via -SkipWindowsUpdate). Disable now so that +# every linked clone spawned from the BaseClean snapshot never triggers background +# updates, spurious reboots, or download lock contention on the shared NVMe. + +# Stop + disable main WU service +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Set-Service -Name wuauserv -StartupType Disabled +# Write Start=4 directly to the service registry key in addition to the SCM call. +# Set-Service calls ChangeServiceConfig (SCM API) which WaaSMedicSvc can override via its +# own SCM call. The registry Start value requires WaaSMedicSvc to have registry write +# access — denied by the ACL block below. +Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' ` + -Name Start -Value 4 -Type DWord -Force + +# Stop + disable Update Orchestrator Service (UsoSvc) — WU client in Server 2022 +Stop-Service -Name UsoSvc -Force -ErrorAction SilentlyContinue +Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue + +Write-Host "wuauserv and UsoSvc set to Disabled." + +# GPO registry keys — survive service self-healing and future WU self-repair attempts +$wuKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' +$wuAUKey = "$wuKey\AU" +if (-not (Test-Path $wuKey)) { New-Item -Path $wuKey -Force | Out-Null } +if (-not (Test-Path $wuAUKey)) { New-Item -Path $wuAUKey -Force | Out-Null } + +# Block WU API + UI access entirely +Set-ItemProperty -Path $wuKey -Name 'DisableWindowsUpdateAccess' -Value 1 -Type DWord -Force +# AutoUpdate policy: AUOptions=1 = disabled; NoAutoUpdate=1 = no background check +Set-ItemProperty -Path $wuAUKey -Name 'NoAutoUpdate' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $wuAUKey -Name 'AUOptions' -Value 1 -Type DWord -Force + +Write-Host "Windows Update GPO policy keys written." + +# Disable WU scheduled tasks (best-effort — some may not exist on every SKU) +$wuTaskPath = '\Microsoft\Windows\WindowsUpdate\' +foreach ($taskName in @('Scheduled Start', 'WakeTimer', 'Automatic App Update', + 'sih', 'sihboot', 'USO_UxBroker')) { + Disable-ScheduledTask -TaskPath $wuTaskPath -TaskName $taskName ` + -ErrorAction SilentlyContinue | Out-Null +} +Write-Host "Windows Update scheduled tasks disabled (best-effort)." + +# Deny WaaSMedicSvc write access to the wuauserv service registry key so it cannot +# reset Start back to Manual (2) or Automatic (3) after we set it to Disabled (4). +# WaaSMedicSvc is a protected service that cannot itself be disabled; ACL denial +# is the only reliable way to prevent it from healing the service startup type. +# Best-effort: wrapped in try/catch so that a WinRM session permission failure is +# non-fatal (the GPO keys + Start=4 still provide a good baseline). +try { + $svcRegPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' + $acl = Get-Acl $svcRegPath + $medicSid = [System.Security.Principal.NTAccount]'NT SERVICE\WaaSMedicSvc' + $denyRule = New-Object System.Security.AccessControl.RegistryAccessRule( + $medicSid, + [System.Security.AccessControl.RegistryRights]'SetValue,CreateSubKey,WriteKey', + [System.Security.AccessControl.InheritanceFlags]::None, + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Deny + ) + $acl.AddAccessRule($denyRule) + Set-Acl $svcRegPath $acl + Write-Host "wuauserv: WaaSMedicSvc write-deny ACL applied." +} catch { + Write-Warning "wuauserv ACL deny skipped (non-fatal — GPO keys + Start=4 still in place): $_" +} + +# Validation +Assert-Step 'WUDisable' 'wuauserv StartType Disabled' { + (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' +} +Assert-Step 'WUDisable' 'NoAutoUpdate=1 GPO key' { + (Get-ItemProperty -Path $wuAUKey -Name NoAutoUpdate -ErrorAction Stop).NoAutoUpdate -eq 1 +} +Assert-Step 'WUDisable' 'DisableWindowsUpdateAccess=1 GPO key' { + (Get-ItemProperty -Path $wuKey -Name DisableWindowsUpdateAccess -ErrorAction Stop).DisableWindowsUpdateAccess -eq 1 +} + +# ── Step 7: Install .NET SDK ────────────────────────────────────────────────── +Write-Step "Installing .NET SDK $DotNetSdkVersion" + +$dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue +if ($dotnetInstalled) { + $installedVersion = dotnet --version + Write-Host ".NET SDK already installed: $installedVersion" +} +else { + Write-Host "Downloading dotnet-install.ps1..." + $dotnetInstallScript = "$env:TEMP\dotnet-install.ps1" + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' ` + -OutFile $dotnetInstallScript -UseBasicParsing + Assert-Hash -FilePath $dotnetInstallScript -Label 'dotnet-install.ps1' ` + -Expected $script:Hashes['DotNetInstallScript'] + + Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..." + & $dotnetInstallScript ` + -Channel $DotNetSdkVersion ` + -InstallDir 'C:\dotnet' ` + -NoPath + + # Add to system PATH + $currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + if ($currentPath -notlike '*C:\dotnet*') { + [System.Environment]::SetEnvironmentVariable( + 'PATH', + "$currentPath;C:\dotnet", + 'Machine' + ) + } + $env:PATH = $env:PATH + ';C:\dotnet' + + Write-Host ".NET SDK installed: $(dotnet --version)" +} + +# Validation +Assert-Step 'DotNet' 'dotnet command resolvable' { + [bool](Get-Command dotnet -ErrorAction SilentlyContinue) +} +Assert-Step 'DotNet' "SDK version starts with channel '$DotNetSdkVersion'" { + $v = (& dotnet --version 2>&1 | Select-Object -First 1).ToString().Trim() + $major = $DotNetSdkVersion.Split('.')[0] + $v.StartsWith("$major.") +} +Assert-Step 'DotNet' 'machine PATH contains C:\dotnet' { + [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') -like '*C:\dotnet*' +} + +# ── Step 8: Install Python ─────────────────────────────────────────────────── +Write-Step "Installing Python $PythonVersion" + +$pythonExe = 'C:\Python\python.exe' +if (Test-Path $pythonExe) { + Write-Host "Python already installed: $(& $pythonExe --version 2>&1)" +} +else { + $pyInstallerUrl = "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe" + $pyInstallerPath = 'C:\CI\python_installer.exe' + Write-Host "Downloading Python $PythonVersion installer..." + Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing + Assert-Hash -FilePath $pyInstallerPath -Label "python-$PythonVersion-amd64.exe" ` + -Expected $script:Hashes['Python'] + + Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..." + $pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @( + '/quiet', + 'InstallAllUsers=1', + 'PrependPath=1', + 'Include_test=0', + 'Include_doc=0', + 'TargetDir=C:\Python' + ) -Wait -PassThru + + Remove-Item $pyInstallerPath -ErrorAction SilentlyContinue + + if ($pyProc -and $pyProc.ExitCode -ne 0) { + throw "Python installation failed (exit $($pyProc.ExitCode))." + } + + # Refresh PATH for subsequent steps + $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + + Write-Host "Python installed: $(python --version 2>&1)" +} + +# Validation +Assert-Step 'Python' 'C:\Python\python.exe present' { + Test-Path 'C:\Python\python.exe' -PathType Leaf +} +Assert-Step 'Python' "version equals $PythonVersion" { + $v = (& 'C:\Python\python.exe' --version 2>&1 | Select-Object -First 1).ToString().Trim() + $v -match [regex]::Escape($PythonVersion) +} +Assert-Step 'Python' 'PATH contains C:\Python (machine or user)' { + $combined = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + $combined -like '*C:\Python*' +} + +# ── Step 9: Install Visual Studio Build Tools 2026 ─────────────────────────── +Write-Step "Installing Visual Studio Build Tools 2026" + +$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe' +if (Test-Path $msbuildPath) { + Write-Host "VS Build Tools 2026 already installed." +} +else { + $vsInstallerUrl = 'https://aka.ms/vs/stable/vs_buildtools.exe' + $vsInstallerPath = 'C:\CI\vs_BuildTools.exe' + $vsResultPath = 'C:\CI\vs_result.txt' + $vsLogPath = 'C:\CI\vs_log.txt' + + Write-Host "Downloading VS Build Tools installer..." + Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy + Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing + Assert-Hash -FilePath $vsInstallerPath -Label 'vs_buildtools.exe' ` + -Expected $script:Hashes['VSBuildToolsBootstrapper'] + + # Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet). + # SYSTEM account cannot execute them without this unblock step. + Unblock-File -Path $vsInstallerPath -ErrorAction SilentlyContinue + + # Verify the download produced a valid Windows PE file (MZ header) + $mzBytes = [System.IO.File]::ReadAllBytes($vsInstallerPath) | Select-Object -First 2 + if (-not ($mzBytes[0] -eq 0x4D -and $mzBytes[1] -eq 0x5A)) { + $fileSize = (Get-Item $vsInstallerPath).Length + throw "VS installer download appears invalid (size=$fileSize, not an EXE). Check the URL: $vsInstallerUrl" + } + Write-Host "Installer verified OK ($((Get-Item $vsInstallerPath).Length) bytes)." + + # VS installer fails under WinRM (network logon token). Run via Scheduled Task as SYSTEM. + $vsWorkerPath = 'C:\CI\vs_worker.ps1' + $vsWorker = @" +# SYSTEM account may have no TEMP set — use C:\Windows\Temp +`$env:TEMP = 'C:\Windows\Temp' +`$env:TMP = 'C:\Windows\Temp' + +# Clean any corrupt VS installer cache left by previous failed attempts +Remove-Item 'C:\ProgramData\Microsoft\VisualStudio\Packages' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item 'C:\Windows\Temp\vs*' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item 'C:\Windows\Temp\dd_*' -Force -ErrorAction SilentlyContinue + +`$result = try { + `$proc = Start-Process -FilePath '$vsInstallerPath' -ArgumentList @( + '--quiet','--wait','--norestart','--nocache', + '--installPath "C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools"', + '--add','Microsoft.VisualStudio.Workload.VCTools', + '--includeRecommended', + '--add','Microsoft.VisualStudio.Workload.MSBuildTools', + '--add','Microsoft.VisualStudio.Workload.ManagedDesktopBuildTools', + '--add','Microsoft.VisualStudio.Component.NuGet.BuildTools', + '--add','Microsoft.Net.Component.4.8.SDK', + '--add','Microsoft.Net.Component.4.7.2.TargetingPack' + ) -Wait -PassThru + if (`$proc) { [string]`$proc.ExitCode } else { '99' } +} catch { + "99: `$_" +} +if (-not `$result) { `$result = '99' } +`$result | Set-Content '$vsResultPath' -Encoding UTF8 +"@ + $vsWorker | Set-Content -Path $vsWorkerPath -Encoding UTF8 + + Remove-Item $vsResultPath -ErrorAction SilentlyContinue + + $taskName = 'CI-VSBuildTools' + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$vsWorkerPath`"" + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest + Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null + + Write-Host "Installing VS Build Tools via Scheduled Task (this may take 15-30 minutes)..." + Start-ScheduledTask -TaskName $taskName + + $timeout = [datetime]::UtcNow.AddMinutes(60) + $dots = 0 + while (-not (Test-Path $vsResultPath)) { + if ([datetime]::UtcNow -gt $timeout) { throw "VS Build Tools installation timed out." } + # If task already finished without writing the result file, the worker itself crashed + $taskInfo = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($taskInfo -and $taskInfo.State -eq 'Ready') { + $lastResult = (Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue).LastTaskResult + throw "VS worker task exited prematurely (LastTaskResult=0x$('{0:X}' -f $lastResult)). Check C:\CI\vs_worker.ps1 for syntax errors." + } + Start-Sleep -Seconds 20 + $dots++ + if ($dots % 3 -eq 0) { Write-Host " ... still installing ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" } + } + + $rawResult = Get-Content $vsResultPath -Raw -ErrorAction SilentlyContinue + $rawResult = if ($rawResult) { $rawResult.Trim() } else { '99' } + # Result file may contain "0", "3010", or "99: " + $vsExit = if ($rawResult -match '^\d+$') { [int]$rawResult } else { 99 } + $vsMsg = if ($vsExit -eq 99) { $rawResult } else { '' } + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-Item $vsWorkerPath, $vsResultPath, $vsInstallerPath -ErrorAction SilentlyContinue + + if ($vsExit -notin @(0, 3010)) { + throw "VS Build Tools installation failed (exit $vsExit). $vsMsg" + } + Write-Host "VS Build Tools 2026 installed (exit code $vsExit)." +} + +# Add MSBuild to system PATH +$msbuildDir = Split-Path $msbuildPath -Parent +$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') +if ($currentPath -notlike "*$msbuildDir*") { + [System.Environment]::SetEnvironmentVariable( + 'PATH', + "$currentPath;$msbuildDir", + 'Machine' + ) + Write-Host "MSBuild added to system PATH." +} + +# Validation +Assert-Step 'VSBuildTools' "MSBuild.exe exists at expected path" { + Test-Path -Path $msbuildPath -PathType Leaf +} +Assert-Step 'VSBuildTools' 'MSBuild executes and reports version' { + $out = & $msbuildPath -version 2>&1 | Select-Object -Last 1 + $out -match '^\d+\.\d+' +} +Assert-Step 'VSBuildTools' 'machine PATH contains MSBuild dir' { + [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') -like "*$msbuildDir*" +} +Assert-Step 'VSBuildTools' 'VC tools install dir present' { + Test-Path 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\VC' -PathType Container +} + +# ── Step 10: Verify toolchain ───────────────────────────────────────────────── +Write-Step "Verifying toolchain" + +$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + +$allOk = $true + +# Helper: resolve a binary — tries hardcoded path first, then Get-Command (PATH) +function Resolve-Tool { + param([string]$HardcodedPath, [string]$CommandName) + if ($HardcodedPath -and (Test-Path $HardcodedPath -PathType Leaf)) { + return $HardcodedPath + } + $found = Get-Command $CommandName -ErrorAction SilentlyContinue + if ($found) { return $found.Source } + return $null +} + +# dotnet +$dotnetBin = Resolve-Tool '' 'dotnet' +if ($dotnetBin) { + $v = & $dotnetBin --version 2>&1 | Select-Object -First 1 + Write-Host " [OK] dotnet: $v ($dotnetBin)" -ForegroundColor Green +} else { + Write-Host " [FAIL] dotnet not found" -ForegroundColor Red + $allOk = $false +} + +# python +$pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python' +if ($pythonBin) { + $v = & $pythonBin --version 2>&1 | Select-Object -First 1 + Write-Host " [OK] python: $v ($pythonBin)" -ForegroundColor Green +} else { + Write-Host " [FAIL] python not found" -ForegroundColor Red + $allOk = $false +} + +# msbuild +$msbuildBin = Resolve-Tool $msbuildPath 'msbuild' +if ($msbuildBin) { + $v = & $msbuildBin -version 2>&1 | Select-Object -First 1 + Write-Host " [OK] msbuild: $v ($msbuildBin)" -ForegroundColor Green +} else { + Write-Host " [FAIL] msbuild not found at '$msbuildPath' and not in PATH" -ForegroundColor Red + $allOk = $false +} + +if (-not $allOk) { + throw "Toolchain verification failed. See [FAIL] entries above. Setup aborted." +} +Write-Host "All toolchain checks passed." -ForegroundColor Green + +# ── Cleanup ─────────────────────────────────────────────────────────────────── +if ($SkipCleanup) { + Write-Host "`n[Setup] Skipping disk cleanup (-SkipCleanup switch set)." -ForegroundColor Yellow +} +else { +Write-Step "Cleaning up disk" + +# 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags +# First register the flags via /sageset:1 in the registry (silent, no UI) +$cleanKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches' +$sageset = 1 +$cleanCategories = @( + 'Active Setup Temp Folders', + 'BranchCache', + 'Downloaded Program Files', + 'Internet Cache Files', + 'Memory Dump Files', + 'Old ChkDsk Files', + 'Previous Installations', + 'Recycle Bin', + 'Service Pack Cleanup', + 'Setup Log Files', + 'System error memory dump files', + 'System error minidump files', + 'Temporary Files', + 'Temporary Setup Files', + 'Thumbnail Cache', + 'Update Cleanup', + 'Upgrade Discarded Files', + 'Windows Defender', + 'Windows Error Reporting Archive Files', + 'Windows Error Reporting Files', + 'Windows Error Reporting Queue Files', + 'Windows Error Reporting Temp Files', + 'Windows ESD installation files', + 'Windows Upgrade Log Files' +) +foreach ($cat in $cleanCategories) { + $keyPath = "$cleanKey\$cat" + if (Test-Path $keyPath) { + Set-ItemProperty -Path $keyPath -Name "StateFlags$('{0:D4}' -f $sageset)" -Value 2 -Type DWord -Force -ErrorAction SilentlyContinue + } +} +Write-Host "Running cleanmgr /sagerun:$sageset (may take a few minutes)..." +$proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" -Wait -PassThru +Write-Host "cleanmgr exited (code $($proc.ExitCode))." + +# 2. Clear Windows Update cache +# Stop all services that might hold file locks inside SoftwareDistribution: +# wuauserv — Windows Update (already Disabled from Step 6b, stop is idempotent) +# UsoSvc — Update Orchestrator (already Disabled from Step 6b) +# bits — Background Intelligent Transfer Service (downloads WU payloads) +# dosvc — Delivery Optimization (peer-to-peer WU cache) +# TrustedInstaller — Windows Modules Installer (unpacks .cab/.msu update packages) +Write-Host "Stopping WU-adjacent services before cache clear..." +foreach ($svc in 'wuauserv', 'UsoSvc', 'bits', 'dosvc', 'TrustedInstaller') { + Stop-Service -Name $svc -Force -ErrorAction SilentlyContinue +} + +# Delete and recreate the Download folder — more reliable than 'Remove-Item *' +# because locked files inside subdirectories silently fail with wildcard delete. +$sdDownload = 'C:\Windows\SoftwareDistribution\Download' +Remove-Item $sdDownload -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $sdDownload -Force | Out-Null +Write-Host "Windows Update download cache cleared." + +# 3. Clear CBS / component store logs +Remove-Item 'C:\Windows\Logs\CBS\*' -Force -ErrorAction SilentlyContinue + +# 4. Clear TEMP folders +Remove-Item 'C:\Windows\Temp\*' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue + +# 5. Clear Prefetch +Remove-Item 'C:\Windows\Prefetch\*' -Force -ErrorAction SilentlyContinue + +# 6. Run DISM component store cleanup (removes superseded update components) +Write-Host "Running DISM component store cleanup (StartComponentCleanup)..." +$dism = Start-Process -FilePath 'dism.exe' ` + -ArgumentList '/Online','/Cleanup-Image','/StartComponentCleanup','/ResetBase' ` + -Wait -PassThru +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 +} +Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' ` + -Name Start -Value 4 -Type DWord -Force -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 — +# and actively restores WU files immediately after deletion. Files reappearing here is +# normal and harmless: WU is permanently disabled by Step 6b (service Disabled + GPO keys). +# Report remaining size as informational only. +$sdFiles = Get-ChildItem 'C:\Windows\SoftwareDistribution\Download' -Recurse -File -Force -ErrorAction SilentlyContinue +# Avoid Measure-Object .Sum: under Set-StrictMode -Version Latest, PS 5.1 treats the +# nullable Sum property as absent when the pipe is empty, throwing "property not found". +$sdBytes = [long]0 +if ($sdFiles) { foreach ($f in $sdFiles) { $sdBytes += $f.Length } } +$sdMB = [math]::Round($sdBytes / 1MB, 1) +Write-Host " SoftwareDistribution\Download: $(@($sdFiles).Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)." + +Assert-Step 'Cleanup' 'wuauserv StartType Disabled after cache clear' { + (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' +} +} # end if (-not $SkipCleanup) + +# ── Pre-Final re-affirmation ────────────────────────────────────────────────── +# Steps 7-10 (installs, cleanup, DISM) can take hours. During that window background +# tasks or WaaSMedicSvc may reset AutoAdminLogon or wuauserv. Re-affirm both here +# immediately before the Final gate so the snapshot captures clean state. + +$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' +$wlNow = Get-ItemProperty -Path $wlKey -ErrorAction SilentlyContinue +if ($wlNow.AutoAdminLogon -ne '1' -or $wlNow.DefaultUserName -ne 'Administrator') { + Set-ItemProperty -Path $wlKey -Name AutoAdminLogon -Value '1' -Type String -Force + Set-ItemProperty -Path $wlKey -Name DefaultUserName -Value 'Administrator' -Type String -Force + Set-ItemProperty -Path $wlKey -Name DefaultDomainName -Value '.' -Type String -Force +} +if ($AdminPassword -ne '') { + Set-ItemProperty -Path $wlKey -Name DefaultPassword -Value $AdminPassword -Type String -Force +} + +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Set-Service -Name wuauserv -StartupType Disabled +Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' ` + -Name Start -Value 4 -Type DWord -Force + +# ── Final pre-snapshot validation ──────────────────────────────────────────── +Write-Step "Final pre-snapshot validation" + +Assert-Step 'Final' 'WinRM service Running' { (Get-Service WinRM -ErrorAction Stop).Status -eq 'Running' } +Assert-Step 'Final' 'all firewall profiles disabled' { + -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true) +} +Assert-Step 'Final' "user $BuildUsername present + admin" { + (Get-LocalUser -Name $BuildUsername -ErrorAction Stop) -and + (& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) +} +Assert-Step 'Final' 'UAC EnableLUA=0' { + (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA).EnableLUA -eq 0 +} +Assert-Step 'Final' 'dotnet, python, msbuild all resolvable' { + $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + (Get-Command dotnet -ErrorAction SilentlyContinue) -and + (Test-Path 'C:\Python\python.exe' -PathType Leaf) -and + (Test-Path $msbuildPath -PathType Leaf) +} +Assert-Step 'Final' 'AutoAdminLogon=1, DefaultUserName=Administrator' { + $wl = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop + $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' +} +Assert-Step 'Final' 'Windows Update permanently disabled' { + (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' +} +Assert-Step 'Final' 'no leftover installer scripts in C:\CI' { + -not (Test-Path 'C:\CI\python_installer.exe') -and + -not (Test-Path 'C:\CI\vs_BuildTools.exe') -and + -not (Test-Path 'C:\CI\wu_worker.ps1') -and + -not (Test-Path 'C:\CI\vs_worker.ps1') +} + +Write-Host "All pre-snapshot checks passed." -ForegroundColor Green + +# ── Done ────────────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "Setup-WinBuild2022.ps1 complete. All Assert-Step checks passed." -ForegroundColor Green +# Full next-steps banner is printed by Prepare-WinBuild2022.ps1 on the host. +# If running this script standalone (manually inside the VM), follow the steps +# in docs/WINDOWS-TEMPLATE-SETUP.md (shut down → snapshot BaseClean → power off). +Write-Host ""