diff --git a/template/Prepare-TemplateSetup.ps1 b/template/Prepare-TemplateSetup.ps1 index 1e0edfe..2f80703 100644 --- a/template/Prepare-TemplateSetup.ps1 +++ b/template/Prepare-TemplateSetup.ps1 @@ -5,10 +5,22 @@ .DESCRIPTION Automates the template VM provisioning from the host machine: - 1. Verifies the VM is reachable via WinRM (must already be enabled in guest) - 2. Copies Setup-TemplateVM.ps1 into the VM via WinRM - 3. Runs Setup-TemplateVM.ps1 inside the VM with the given parameters - 4. Handles the reboot-required case (exit 3010) and optionally re-runs + 1. Pre-flight validation: script presence, IP octet range, TCP/5985 reachable + 2. Configures host WinRM client (AllowUnencrypted, TrustedHosts) — restored on exit + 3. Validates host WinRM client settings applied correctly + 4. Tests WinRM connectivity (Test-WSMan) — fails fast with actionable message + 5. Opens PSSession to the VM + 6. Ensures C:\CI exists on guest; validates creation + 7. Copies Setup-TemplateVM.ps1 into the VM; validates file present + size match + 8. Runs Setup-TemplateVM.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 WinRM client settings 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 @@ -20,7 +32,7 @@ (check via: ipconfig inside the VM, or VMware → VM → Settings → Network Adapter → Advanced → MAC address, then check DHCP leases) - AFTER THIS SCRIPT COMPLETES: + 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) @@ -28,8 +40,19 @@ 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 while it is on NAT (VMnet8). + 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 @@ -40,7 +63,11 @@ .PARAMETER BuildPassword Password to set for the ci_build user inside the VM. - Default: CIBuild!ChangeMe2026 — CHANGE THIS before production use. + 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-TemplateVM.ps1 and used in post-setup validation. .PARAMETER DotNetSdkVersion .NET SDK channel to install in the VM. Default: 10.0 (matches host SDK). @@ -48,22 +75,51 @@ .PARAMETER SkipWindowsUpdate Pass through to Setup-TemplateVM.ps1 to skip the Windows Update step. -.EXAMPLE - # Interactive (prompts for admin password): - .\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 +.PARAMETER SnapshotName + Name of the snapshot to create. Default: BaseClean (case-sensitive — used by New-BuildVM.ps1). + Only relevant when -TakeSnapshot is set. - # With explicit credentials: - .\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 ` - -AdminPassword 'YourAdminPass' -BuildPassword 'StrongCIPass!2026' +.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-TemplateSetup.ps1 ` + -VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' ` + -BuildPassword 'StrongCIPass!2026' -StoreCredential # Skip Windows Update (already applied): - .\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 -SkipWindowsUpdate + .\Prepare-TemplateSetup.ps1 ` + -VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' ` + -SkipWindowsUpdate -StoreCredential + + # Explicit IP (manual start, no VMware Tools required for IP detection): + .\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 ` + -BuildPassword 'StrongCIPass!2026' -StoreCredential + + # Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress: + .\Prepare-TemplateSetup.ps1 ` + -VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' -VMIPAddress 192.168.79.130 ` + -BuildPassword 'StrongCIPass!2026' -StoreCredential #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] - [string] $VMIPAddress, + # 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', @@ -71,16 +127,119 @@ param( [string] $BuildPassword = '', + [string] $BuildUsername = 'ci_build', + [string] $DotNetSdkVersion = '10.0', - [switch] $SkipWindowsUpdate + [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-TemplateVM.ps1 present alongside this script' { + Test-Path (Join-Path $scriptDir 'Setup-TemplateVM.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`:5985 (timeout ${winrmTimeoutSec}s)..." +$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec) +$winrmReady = $false +while ([datetime]::UtcNow -lt $winrmDeadline) { + if (Test-NetConnection -ComputerName $VMIPAddress -Port 5985 ` + -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/5985" { $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:" @@ -119,6 +278,14 @@ try { Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop } Write-Host "[Prepare] Host WinRM client configured." + # Validation + Assert-Step 'HostWinRM' 'Client/AllowUnencrypted=true' { + (Get-Item WSMan:\localhost\Client\AllowUnencrypted).Value -eq 'true' + } + 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 client settings (need elevation?)." @@ -174,16 +341,48 @@ try { 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-TemplateVM.ps1 -> guest $guestScriptPath ..." - Copy-Item -Path $setupScript -Destination $guestScriptPath -ToSession $session -Force + Write-Host "[Prepare] Copying Setup-TemplateVM.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-TemplateVM.ps1 ───────────────────────── $setupArgs = @{ BuildPassword = $BuildPassword + BuildUsername = $BuildUsername DotNetSdkVersion = $DotNetSdkVersion } if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true } + if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true } # ── Run Setup-TemplateVM.ps1 inside the VM ──────────────────────────────── Write-Host "[Prepare] Running Setup-TemplateVM.ps1 inside VM (this will take a while)..." @@ -225,30 +424,188 @@ try { throw "Setup-TemplateVM.ps1 exited with code $result" } - # ── Success ─────────────────────────────────────────────────────────────── - Write-Host "" - Write-Host "============================================================" -ForegroundColor Green - Write-Host " Template VM provisioning complete!" -ForegroundColor Green - Write-Host "============================================================" -ForegroundColor Green - Write-Host "" - Write-Host " NOW DO THESE STEPS (manual):" - 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:" - Write-Host " VM -> Snapshot -> Take Snapshot" - Write-Host " Name it EXACTLY: BaseClean" - Write-Host "" - Write-Host " 3. Store CI credentials on this HOST:" - Write-Host " (run in elevated PowerShell with CredentialManager module)" - Write-Host " New-StoredCredential -Target 'BuildVMGuest' ``" - Write-Host " -UserName 'ci_build' -Password '' ``" - Write-Host " -Persist LocalMachine" - Write-Host "" - Write-Host " 4. Verify runner online in Gitea: http://10.10.20.11:3100" - Write-Host " Admin -> Runners -> local-windows-runner -> should show Online" - Write-Host "" + # ── 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-TemplateSetup.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 diff --git a/template/Setup-TemplateVM.ps1 b/template/Setup-TemplateVM.ps1 index b941d79..3cccac5 100644 --- a/template/Setup-TemplateVM.ps1 +++ b/template/Setup-TemplateVM.ps1 @@ -1,4 +1,4 @@ -#Requires -RunAsAdministrator +#Requires -RunAsAdministrator #Requires -Version 5.1 <# .SYNOPSIS @@ -8,13 +8,68 @@ This script is run ONE TIME inside the template VM before taking the "BaseClean" snapshot. After the snapshot is taken, never modify the VM. - Installs and configures: - - Windows Updates (all available, via COM API — no external module needed) - - WinRM with basic authentication (for PSSession from host) - - A dedicated local build user account - - Visual Studio Build Tools 2026 (MSBuild, MSVC, .NET desktop targets) - - .NET SDK 10.x - - Firewall rules for WinRM + Installs and configures (each step followed by Assert-Step validation): + Step 1 — Firewall: all profiles disabled first — removes any block on subsequent steps + Validation: Domain/Private/Public all Enabled=False + Step 2 — Defender & Antimalware disabled — before WinRM and Windows Update + Set-MpPreference + GPO registry keys; exclusions C:\CI, C:\dotnet, C:\Python + Validation: 6 GPO keys + MpPreference (if WinDefend service present) + Rationale: WU downloads + VS/Python/dotnet installers run unscanned; + saves 5-15 min; WinRM traffic not inspected + Step 3 — WinRM: Basic auth, AllowUnencrypted, MaxMemoryPerShellMB=2048, 2h timeout + Firewall + Defender already off — no interference during config + Validation: service Running+Automatic, AllowUnencrypted, Auth/Basic, memory + Step 4 — Local build user account ($BuildUsername, PasswordNeverExpires, Administrators) + Validation: user exists, enabled, PasswordNeverExpires, admin membership + Step 4b — UAC disabled (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 (This PC) for current user + Default profile hive + Validation: HKCU LaunchTo=1 + Step 5c — CI UX hardening (all UI tweaks before the long-running WU step): + Server Manager auto-start off (machine policy + HKCU Administrator + + Default User hive → ci_build inherits at first logon) + Ctrl+Alt+Del at login disabled (DisableCAD=1) — CI VMs never need it + OOBE privacy/telemetry prompt suppressed (DisablePrivacyExperience=1, + AllowTelemetry=0) — prevents first-logon interactive prompt in clones + Validation: machine policy key, HKCU key, DisableCAD, OOBE policy, telemetry + Step 6 — Windows Update via Scheduled Task as SYSTEM (avoids WinRM token limit) + Runs with Firewall + Defender off, C:\CI present — faster, no scan overhead + Validation: result code 0/2/3, no reboot required (else exit 3010) + Step 6b — Windows Update permanently disabled (post-update lockdown) + wuauserv + UsoSvc set to Disabled; GPO keys NoAutoUpdate=1, + DisableWindowsUpdateAccess=1; WU scheduled tasks disabled + Runs unconditionally (even with -SkipWindowsUpdate) so snapshot + captures WU off — linked clones never trigger background updates + 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 (was: warn-only) + Cleanup — Disk cleanup: cleanmgr, SoftwareDistribution, CBS, TEMP, Prefetch, DISM + wuauserv already Disabled (Step 6b) — not restarted after cache clear + SoftwareDistribution\Download cleared best-effort (WaaSMedicSvc may repopulate) + Validation: wuauserv StartType Disabled (SoftwareDistribution check removed — + WaaSMedicSvc tamper-protection restores files immediately) + 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 first — removes any network block before WinRM config and WU downloads + Defender second — eliminates scan overhead before WinRM, WU, and installer downloads + WinRM third — configured clean with no Firewall/Defender interference + User + UAC — quick registry ops; UAC off before dirs/tools avoids elevation prompts + CI dirs — C:\CI must exist before WU worker writes temp files there + UI tweaks — all fast registry writes batched before the slow WU step + WU sixth — all prereqs (dirs, user, tweaks) done; Firewall+Defender off + WU disable — immediately after WU completes; snapshot captures WU permanently off + Toolchain — .NET / Python / VS last; WU done, no scan on installer payloads NOTE: Git is NOT installed. The host clones the repository and copies the source tree into the VM via WinRM (Option B isolation model). @@ -27,13 +82,27 @@ ║ for pip/nuget downloads at runtime). ║ ╚══════════════════════════════════════════════════════════════════════╝ - After this script completes: + 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-TemplateSetup.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). @@ -41,7 +110,7 @@ Invoke via Prepare-TemplateSetup.ps1 from the HOST (recommended), or run manually from an elevated PowerShell session INSIDE the VM: Set-ExecutionPolicy Bypass -Scope Process -Force - .\Setup-TemplateVM.ps1 + .\Setup-TemplateVM.ps1 -BuildPassword 'YourPassword' #> [CmdletBinding()] param( @@ -61,7 +130,10 @@ param( [string] $PythonVersion = '3.13.3', # Skip Windows Update (useful when updates already applied or no internet) - [switch] $SkipWindowsUpdate + [switch] $SkipWindowsUpdate, + + # Skip disk cleanup step (cleanmgr + DISM + cache clear) — speeds up re-runs during dev/debug + [switch] $SkipCleanup ) Set-StrictMode -Version Latest @@ -72,7 +144,382 @@ function Write-Step { Write-Host "`n=== $Message ===" -ForegroundColor Cyan } -# ── Step 0: Windows Update ──────────────────────────────────────────────────── +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 +} + +# ── 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 ────────────────────────────────────────────────────────── +Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM" + +# Disable first: 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, so full disable is acceptable. +Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False +Write-Host "Windows Firewall disabled on all profiles." + +# Validation +foreach ($p in 'Domain','Private','Public') { + Assert-Step 'Firewall' "$p profile disabled" { + (Get-NetFirewallProfile -Profile $p -ErrorAction Stop).Enabled -eq $false + } +} + +# ── Step 2: Disable Windows Defender & Antimalware ─────────────────────────── +Write-Step "Disabling Windows Defender & Antimalware" + +# Disabled before WinRM config and Windows Update so that: +# - WU downloads/installs are not scanned (saves 5-15 min on update-heavy runs) +# - WinRM traffic is not intercepted by network inspection +# On an isolated CI template VM this trade-off is acceptable. +$defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue +if ($defSvc) { + Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null + Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null + Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null + Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null + # Exclude CI build paths from any remaining scanning + Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null + Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null + Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null + Write-Host "Windows Defender real-time protection disabled." +} +else { + Write-Host "Windows Defender service not found — skipping." +} + +# Disable via Group Policy registry key — survives Defender auto-updates and reboots. +# This is the same key that SCCM/GPO uses in enterprise environments. +$defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' +if (-not (Test-Path $defenderKey)) { + New-Item -Path $defenderKey -Force | Out-Null +} +Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force +# Also disable via the Windows Security Center policy subkey +$rtKey = "$defenderKey\Real-Time Protection" +if (-not (Test-Path $rtKey)) { + New-Item -Path $rtKey -Force | Out-Null +} +Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force +Write-Host "Defender GPO registry keys written (persistent across updates)." + +# Validation — GPO registry keys (always applicable, survives Defender presence) +Assert-Step 'Defender' "GPO DisableAntiSpyware=1" { + (Get-ItemProperty -Path $defenderKey -Name DisableAntiSpyware -ErrorAction Stop).DisableAntiSpyware -eq 1 +} +Assert-Step 'Defender' "GPO DisableAntiVirus=1" { + (Get-ItemProperty -Path $defenderKey -Name DisableAntiVirus -ErrorAction Stop).DisableAntiVirus -eq 1 +} +foreach ($rtName in 'DisableRealtimeMonitoring','DisableBehaviorMonitoring','DisableIOAVProtection','DisableScriptScanning') { + Assert-Step 'Defender' "GPO Real-Time Protection $rtName=1" { + (Get-ItemProperty -Path $rtKey -Name $rtName -ErrorAction Stop).$rtName -eq 1 + } +} +# Validation — Defender preferences (only if service exists) +if ($defSvc) { + $mp = Get-MpPreference -ErrorAction SilentlyContinue + if ($mp) { + Assert-Step 'Defender' 'Set-MpPreference DisableRealtimeMonitoring=true' { $mp.DisableRealtimeMonitoring } + Assert-Step 'Defender' "ExclusionPath contains 'C:\CI'" { $mp.ExclusionPath -contains 'C:\CI' } + } +} + +# ── Step 3: WinRM ───────────────────────────────────────────────────────────── +Write-Step "Configuring WinRM" + +# Firewall and Defender already off — no interference during WinRM hardening. +Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null + +# Allow unencrypted (HTTP/5985) — acceptable for an isolated lab network. +# Migrate to HTTPS/5986 with a self-signed cert for hardened environments. +winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null +winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null + +# Increase shell memory and timeout limits for long builds +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 # 2 hours + +# Ensure WinRM starts automatically +Set-Service -Name WinRM -StartupType Automatic 3>$null +Start-Service WinRM 3>$null + +Write-Host "WinRM configured." + +# Validation +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=true' { + (Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'true' +} +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 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 / WS2025 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: Disable UAC ───────────────────────────────────────────────────── +Write-Step "Disabling UAC (isolated lab VM)" + +# EnableLUA=0 disables UAC entirely — admin processes get full token without prompt. +# LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also +# get an elevated token (avoids the filtered-token issue with runProgramInGuest). +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` + -Name EnableLUA -Value 0 -Type DWord -Force +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` + -Name LocalAccountTokenFilterPolicy -Value 1 -Type DWord -Force +Write-Host "UAC disabled." + +# Validation +$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 ─────────────────────────────────────────────── +Write-Step "Configuring Explorer defaults" + +# Open Explorer to 'This PC' (Devices and Drives) instead of Quick Access. +# LaunchTo=1 = This PC, LaunchTo=2 = Quick Access (default) +$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' +Set-ItemProperty -Path $explorerAdvKey -Name 'LaunchTo' -Value 1 -Type DWord -Force + +# Also apply to the Default User profile so ci_build and any new user inherits it. +$defaultHive = 'C:\Users\Default\NTUSER.DAT' +$mountName = 'TempDefaultUser' +if (Test-Path $defaultHive) { + reg load "HKU\$mountName" $defaultHive | Out-Null + $defKey = "Registry::HKU\$mountName\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced" + if (-not (Test-Path $defKey)) { New-Item -Path $defKey -Force | Out-Null } + Set-ItemProperty -Path $defKey -Name 'LaunchTo' -Value 1 -Type DWord -Force + [gc]::Collect() + reg unload "HKU\$mountName" | Out-Null + Write-Host "Explorer: 'This PC' set for current user and default profile." +} +else { + Write-Host "Explorer: 'This PC' set for current user (default hive not found)." +} + +# Validation +Assert-Step 'Explorer' "LaunchTo=1 (HKCU current user)" { + (Get-ItemProperty -Path $explorerAdvKey -Name LaunchTo -ErrorAction Stop).LaunchTo -eq 1 +} + +# ── Step 5c: CI UX hardening ───────────────────────────────────────────────── +Write-Step "CI UX hardening (lock screen off, Server Manager off, no CAD, no OOBE)" + +# All UI tweaks batched here — fast registry writes before the slow WU step. + +# 1 — Lock screen: disable entirely (CI VMs never need a lock screen) +# NoLockScreen=1 suppresses the lock screen shown before login on console sessions. +$personPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' +if (-not (Test-Path $personPolicyKey)) { New-Item -Path $personPolicyKey -Force | Out-Null } +Set-ItemProperty -Path $personPolicyKey -Name 'NoLockScreen' -Value 1 -Type DWord -Force +Write-Host "Lock screen disabled." + +# 2 — Server Manager: do not auto-open at logon +# Three layers needed on WS2025: +# a) Machine policy key (GPO path — read by ServerManager policy check) +# b) HKLM non-policy key (read directly by ServerManager.exe on WS2025) +# c) Scheduled Task '\Microsoft\Windows\Server Manager\ServerManager' +# — this is the actual launcher; registry alone is insufficient on WS2025 +# d) HKCU + Default hive for per-user belt-and-suspenders +$smPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' +if (-not (Test-Path $smPolicyKey)) { New-Item -Path $smPolicyKey -Force | Out-Null } +Set-ItemProperty -Path $smPolicyKey -Name 'DoNotOpenAtLogon' -Value 1 -Type DWord -Force + +$smHKLMKey = 'HKLM:\SOFTWARE\Microsoft\ServerManager' +if (-not (Test-Path $smHKLMKey)) { New-Item -Path $smHKLMKey -Force | Out-Null } +Set-ItemProperty -Path $smHKLMKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force + +Disable-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' ` + -TaskName 'ServerManager' -ErrorAction SilentlyContinue | Out-Null + +# Current session user (Administrator) +$smUserKey = 'HKCU:\SOFTWARE\Microsoft\ServerManager' +if (-not (Test-Path $smUserKey)) { New-Item -Path $smUserKey -Force | Out-Null } +Set-ItemProperty -Path $smUserKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force + +# Default User hive — ci_build and any future account inherits at first logon +$smDefaultHive = 'C:\Users\Default\NTUSER.DAT' +$smMountName = 'TempDefaultUserSM' +if (Test-Path $smDefaultHive) { + reg load "HKU\$smMountName" $smDefaultHive | Out-Null + $smDefKey = "Registry::HKU\$smMountName\SOFTWARE\Microsoft\ServerManager" + if (-not (Test-Path $smDefKey)) { New-Item -Path $smDefKey -Force | Out-Null } + Set-ItemProperty -Path $smDefKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force + [gc]::Collect() + reg unload "HKU\$smMountName" | Out-Null + Write-Host "Server Manager: policy + HKLM + task + Administrator HKCU + Default hive set." +} +else { + Write-Host "Server Manager: policy + HKLM + task + Administrator HKCU set (Default hive not found)." +} + +# 3 — Disable Ctrl+Alt+Del at interactive login +# Set in both locations: Winlogon (classic) + Policies\System (policy, authoritative on WS2025). +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' ` + -Name 'DisableCAD' -Value 1 -Type DWord -Force +$systemPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' +Set-ItemProperty -Path $systemPolicyKey -Name 'DisableCAD' -Value 1 -Type DWord -Force +Write-Host "Ctrl+Alt+Del at login disabled (Winlogon + Policies\System)." + +# 4 — Suppress OOBE privacy/telemetry prompt +$oobePolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' +if (-not (Test-Path $oobePolicyKey)) { New-Item -Path $oobePolicyKey -Force | Out-Null } +Set-ItemProperty -Path $oobePolicyKey -Name 'DisablePrivacyExperience' -Value 1 -Type DWord -Force + +$oobeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' +if (-not (Test-Path $oobeKey)) { New-Item -Path $oobeKey -Force | Out-Null } +Set-ItemProperty -Path $oobeKey -Name 'DisablePrivacyExperience' -Value 1 -Type DWord -Force + +$dcKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' +if (-not (Test-Path $dcKey)) { New-Item -Path $dcKey -Force | Out-Null } +Set-ItemProperty -Path $dcKey -Name 'AllowTelemetry' -Value 0 -Type DWord -Force +Write-Host "OOBE privacy prompt and telemetry disabled." + +# Validation +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 +} + +# ── 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). @@ -93,14 +540,18 @@ $ErrorActionPreference = 'Stop' try { $s = New-Object -ComObject Microsoft.Update.Session $q = $s.CreateUpdateSearcher() - $found = $q.Search("IsInstalled=0 and Type='Software' and IsHidden=0") + $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." | Set-Content C:\CI\wu_log.txt + "No updates needed (0 updates found by COM API)." | Set-Content C:\CI\wu_log.txt } else { - "Downloading $count update(s)..." | Set-Content C:\CI\wu_log.txt + $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 @@ -108,8 +559,8 @@ try { $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 + [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 { @@ -120,6 +571,38 @@ try { '@ $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 @@ -175,160 +658,58 @@ try { } } +# ── Step 6b: Disable Windows Update permanently (post-update lockdown) ─────── +Write-Step "Disabling Windows Update permanently (post-update snapshot lockdown)" -# ── Step 1: WinRM ───────────────────────────────────────────────────────────── -Write-Step "Configuring WinRM" +# 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. -Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null +# Stop + disable main WU service +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Set-Service -Name wuauserv -StartupType Disabled -# Allow unencrypted (HTTP/5985) — acceptable for an isolated lab network. -# Migrate to HTTPS/5986 with a self-signed cert for hardened environments. -winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null -winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null +# Stop + disable Update Orchestrator Service (UsoSvc) — WU client in Server 2025 +Stop-Service -Name UsoSvc -Force -ErrorAction SilentlyContinue +Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue -# Increase shell memory and timeout limits for long builds -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 # 2 hours +Write-Host "wuauserv and UsoSvc set to Disabled." -# Ensure WinRM starts automatically -Set-Service -Name WinRM -StartupType Automatic 3>$null -Start-Service WinRM 3>$null +# 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 } -Write-Host "WinRM configured." +# 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 -# ── Step 2: Firewall ────────────────────────────────────────────────────────── -Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM" +Write-Host "Windows Update GPO policy keys written." -# Disable all firewall profiles entirely. This VM lives on VMnet8 NAT behind -# the VMware NAT gateway with no inbound exposure from outside the host, so -# full disable is acceptable and avoids profile-classification issues blocking -# WinRM/ICMP from the host. -Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False -Write-Host "Windows Firewall disabled on all profiles." - -# ── Step 3: 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." +# 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 } -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." +Write-Host "Windows Update scheduled tasks disabled (best-effort)." + +# 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 } -# 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." -} - -# ── Step 3b: Disable UAC ────────────────────────────────────────────────────── -Write-Step "Disabling UAC (isolated lab VM)" - -# EnableLUA=0 disables UAC entirely — admin processes get full token without prompt. -# LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also -# get an elevated token (avoids the filtered-token issue with runProgramInGuest). -Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` - -Name EnableLUA -Value 0 -Type DWord -Force -Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` - -Name LocalAccountTokenFilterPolicy -Value 1 -Type DWord -Force -Write-Host "UAC disabled." - -# ── Step 4: CI working directories ─────────────────────────────────────────── -Write-Step "Creating CI working directories" - -foreach ($dir in @('C:\CI\build', 'C:\CI\output', 'C:\CI\scripts')) { - if (-not (Test-Path $dir)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - Write-Host "Created: $dir" - } -} - -# ── Step 4b: Explorer settings ─────────────────────────────────────────────── -Write-Step "Configuring Explorer defaults" - -# Open Explorer to 'This PC' (Devices and Drives) instead of Quick Access. -# LaunchTo=1 = This PC, LaunchTo=2 = Quick Access (default) -$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' -Set-ItemProperty -Path $explorerAdvKey -Name 'LaunchTo' -Value 1 -Type DWord -Force - -# Also apply to the Default User profile so ci_build and any new user inherits it. -$defaultHive = 'C:\Users\Default\NTUSER.DAT' -$mountName = 'TempDefaultUser' -if (Test-Path $defaultHive) { - reg load "HKU\$mountName" $defaultHive | Out-Null - $defKey = "Registry::HKU\$mountName\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced" - if (-not (Test-Path $defKey)) { New-Item -Path $defKey -Force | Out-Null } - Set-ItemProperty -Path $defKey -Name 'LaunchTo' -Value 1 -Type DWord -Force - [gc]::Collect() - reg unload "HKU\$mountName" | Out-Null - Write-Host "Explorer: 'This PC' set for current user and default profile." -} -else { - Write-Host "Explorer: 'This PC' set for current user (default hive not found)." -} - -# ── Step 5: Disable Windows Defender ──────────────────────────────────────── -Write-Step "Disabling Windows Defender real-time protection" - -# Defender scanning significantly slows compilation and NuGet restore. -# On an isolated CI template VM this trade-off is acceptable. -$defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue -if ($defSvc) { - Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null - Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null - Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null - Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null - # Exclude CI build paths from any remaining scanning - Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null - Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null - Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null - Write-Host "Windows Defender real-time protection disabled." -} -else { - Write-Host "Windows Defender service not found — skipping." -} - -# Disable via Group Policy registry key — survives Defender auto-updates and reboots. -# This is the same key that SCCM/GPO uses in enterprise environments. -$defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' -if (-not (Test-Path $defenderKey)) { - New-Item -Path $defenderKey -Force | Out-Null -} -Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force -# Also disable via the Windows Security Center policy subkey -$rtKey = "$defenderKey\Real-Time Protection" -if (-not (Test-Path $rtKey)) { - New-Item -Path $rtKey -Force | Out-Null -} -Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force -Write-Host "Defender GPO registry keys written (persistent across updates)." - -# ── Step 6: Install .NET SDK ───────────────────────────────────────────────── +# ── Step 7: Install .NET SDK ────────────────────────────────────────────────── Write-Step "Installing .NET SDK $DotNetSdkVersion" $dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue @@ -362,7 +743,20 @@ else { Write-Host ".NET SDK installed: $(dotnet --version)" } -# ── Step 7: Install Python ─────────────────────────────────────────────────── +# 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' @@ -398,7 +792,21 @@ else { Write-Host "Python installed: $(python --version 2>&1)" } -# ── Step 8: Install Visual Studio Build Tools 2022 ─────────────────────────── +# 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' @@ -513,7 +921,22 @@ if ($currentPath -notlike "*$msbuildDir*") { Write-Host "MSBuild added to system PATH." } -# ── Step 9: Verify toolchain ─────────────────────────────────────────────── +# 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') + ';' + @@ -536,9 +959,9 @@ function Resolve-Tool { $dotnetBin = Resolve-Tool '' 'dotnet' if ($dotnetBin) { $v = & $dotnetBin --version 2>&1 | Select-Object -First 1 - Write-Host " [OK] dotnet: $v ($dotnetBin)" + Write-Host " [OK] dotnet: $v ($dotnetBin)" -ForegroundColor Green } else { - Write-Warning " [FAIL] dotnet not found" + Write-Host " [FAIL] dotnet not found" -ForegroundColor Red $allOk = $false } @@ -546,9 +969,9 @@ if ($dotnetBin) { $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)" + Write-Host " [OK] python: $v ($pythonBin)" -ForegroundColor Green } else { - Write-Warning " [FAIL] python not found" + Write-Host " [FAIL] python not found" -ForegroundColor Red $allOk = $false } @@ -556,17 +979,22 @@ if ($pythonBin) { $msbuildBin = Resolve-Tool $msbuildPath 'msbuild' if ($msbuildBin) { $v = & $msbuildBin -version 2>&1 | Select-Object -First 1 - Write-Host " [OK] msbuild: $v ($msbuildBin)" + Write-Host " [OK] msbuild: $v ($msbuildBin)" -ForegroundColor Green } else { - Write-Warning " [FAIL] msbuild not found at '$msbuildPath' and not in PATH" + Write-Host " [FAIL] msbuild not found at '$msbuildPath' and not in PATH" -ForegroundColor Red $allOk = $false } if (-not $allOk) { - Write-Warning "Some tools failed verification. Review above and rerun if needed." + 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 @@ -610,10 +1038,22 @@ $proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" Write-Host "cleanmgr exited (code $($proc.ExitCode))." # 2. Clear Windows Update cache -Write-Host "Stopping Windows Update service..." -Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue -Remove-Item 'C:\Windows\SoftwareDistribution\Download\*' -Recurse -Force -ErrorAction SilentlyContinue -Start-Service -Name wuauserv -ErrorAction SilentlyContinue +# 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 @@ -635,25 +1075,58 @@ Write-Host "DISM exited (code $($dism.ExitCode))." Write-Host "Disk cleanup complete." +# 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 +$sdMB = [math]::Round(($sdFiles | Measure-Object -Property Length -Sum).Sum / 1MB, 1) +Write-Host " SoftwareDistribution\Download: $($sdFiles.Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)." + +Assert-Step 'Cleanup' 'wuauserv StartType still Disabled after cache clear' { + (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' +} +} # end if (-not $SkipCleanup) + +# ── 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' '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 "============================================================" -ForegroundColor Green -Write-Host " Template VM setup complete!" -ForegroundColor Green -Write-Host "============================================================" -ForegroundColor Green -Write-Host "" -Write-Host " MANDATORY NEXT STEPS:" -Write-Host "" -Write-Host " 1. Shut down this VM: Start -> Shut down" -Write-Host " (Keep NIC on VMnet8 NAT — internet is required for builds)" -Write-Host "" -Write-Host " 2. Take snapshot in VMware Workstation:" -Write-Host " VM -> Snapshot -> Take Snapshot" -Write-Host " Name it EXACTLY: BaseClean" -Write-Host "" -Write-Host " 3. Leave VM powered off permanently." -Write-Host "" -Write-Host " 4. On the HOST - store credentials in Windows Credential Manager:" -Write-Host " New-StoredCredential -Target 'BuildVMGuest' -UserName '$BuildUsername' ``" -Write-Host " -Password '' -Persist LocalMachine" -Write-Host " (Run in an elevated PowerShell with CredentialManager module)" +Write-Host "Setup-TemplateVM.ps1 complete. All Assert-Step checks passed." -ForegroundColor Green +# Full next-steps banner is printed by Prepare-TemplateSetup.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 ""