feat(template): add Assert-Step validation to all provisioning steps

Setup-TemplateVM.ps1:
- Added Assert-Step validation after each install step (WinRM, Firewall,
  User, UAC, Defender, .NET, Python, VS Build Tools, Toolchain, Cleanup)
- Final pre-snapshot gate: all checks must pass before snapshot is allowed
- Disabled Windows Defender + Firewall before installs (saves 5-15 min)
- Disabled UAC (EnableLUA=0, LocalAccountTokenFilterPolicy=1)

Prepare-TemplateSetup.ps1:
- Pre-flight checks: IP validation, TCP/5985 reachability, script presence
- Host WinRM: AllowUnencrypted, TrustedHosts config
- Guest prep: C:\CI exists, file copied, size match
- Post-setup remote validation (9 checks): WinRM, user+admin, UAC,
  firewall, .NET, Python, MSBuild, CI dirs
- StoreCredential flow: New-StoredCredential for BuildVMGuest target
This commit is contained in:
Simone
2026-05-09 21:15:39 +02:00
parent 44f4c3ce7e
commit d4099b94fd
2 changed files with 1067 additions and 237 deletions
+388 -31
View File
@@ -5,10 +5,22 @@
.DESCRIPTION .DESCRIPTION
Automates the template VM provisioning from the host machine: Automates the template VM provisioning from the host machine:
1. Verifies the VM is reachable via WinRM (must already be enabled in guest) 1. Pre-flight validation: script presence, IP octet range, TCP/5985 reachable
2. Copies Setup-TemplateVM.ps1 into the VM via WinRM 2. Configures host WinRM client (AllowUnencrypted, TrustedHosts) — restored on exit
3. Runs Setup-TemplateVM.ps1 inside the VM with the given parameters 3. Validates host WinRM client settings applied correctly
4. Handles the reboot-required case (exit 3010) and optionally re-runs 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): PREREQUISITES (do these manually before running this script):
a. VM NIC is set to VMnet8 (NAT) — the VM needs internet for downloads 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 → (check via: ipconfig inside the VM, or VMware → VM → Settings →
Network Adapter → Advanced → MAC address, then check DHCP leases) 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 1. Shut down the VM
2. Take snapshot: VM → Snapshot → Take Snapshot → name: BaseClean 2. Take snapshot: VM → Snapshot → Take Snapshot → name: BaseClean
(VM stays on VMnet8 NAT — internet access is required for builds) (VM stays on VMnet8 NAT — internet access is required for builds)
@@ -28,8 +40,19 @@
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' ` New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password '<your-build-password>' -Persist LocalMachine -Password '<your-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 .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 Example: 192.168.79.130
.PARAMETER AdminUsername .PARAMETER AdminUsername
@@ -40,7 +63,11 @@
.PARAMETER BuildPassword .PARAMETER BuildPassword
Password to set for the ci_build user inside the VM. 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 .PARAMETER DotNetSdkVersion
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK). .NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
@@ -48,22 +75,51 @@
.PARAMETER SkipWindowsUpdate .PARAMETER SkipWindowsUpdate
Pass through to Setup-TemplateVM.ps1 to skip the Windows Update step. Pass through to Setup-TemplateVM.ps1 to skip the Windows Update step.
.EXAMPLE .PARAMETER SnapshotName
# Interactive (prompts for admin password): Name of the snapshot to create. Default: BaseClean (case-sensitive — used by New-BuildVM.ps1).
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 Only relevant when -TakeSnapshot is set.
# With explicit credentials: .PARAMETER StoreCredential
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 ` After provisioning completes, store BuildUsername/BuildPassword in Windows
-AdminPassword 'YourAdminPass' -BuildPassword 'StrongCIPass!2026' 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 '<pwd>' -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): # 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()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)] # Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot.
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] # Either -VMXPath or -VMIPAddress (or both) must be supplied.
[string] $VMIPAddress, [string] $VMXPath = '',
# Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via VMware Tools).
[string] $VMIPAddress = '',
[string] $AdminUsername = 'Administrator', [string] $AdminUsername = 'Administrator',
@@ -71,16 +127,119 @@ param(
[string] $BuildPassword = '', [string] $BuildPassword = '',
[string] $BuildUsername = 'ci_build',
[string] $DotNetSdkVersion = '10.0', [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 Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $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 $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. # Prompt for BuildPassword if not supplied — never use a hardcoded default.
if ($BuildPassword -eq '') { if ($BuildPassword -eq '') {
Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:" 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 Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
} }
Write-Host "[Prepare] Host WinRM client configured." 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 { catch {
Write-Warning "[Prepare] Could not configure host WinRM client settings (need elevation?)." 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 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 ..." Write-Host "[Prepare] Copying Setup-TemplateVM.ps1 -> guest $guestScriptPath (UTF-8 BOM)..."
Copy-Item -Path $setupScript -Destination $guestScriptPath -ToSession $session -Force # 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 ───────────────────────── # ── Build argument list for Setup-TemplateVM.ps1 ─────────────────────────
$setupArgs = @{ $setupArgs = @{
BuildPassword = $BuildPassword BuildPassword = $BuildPassword
BuildUsername = $BuildUsername
DotNetSdkVersion = $DotNetSdkVersion DotNetSdkVersion = $DotNetSdkVersion
} }
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true } if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
# ── Run Setup-TemplateVM.ps1 inside the VM ──────────────────────────────── # ── Run Setup-TemplateVM.ps1 inside the VM ────────────────────────────────
Write-Host "[Prepare] Running Setup-TemplateVM.ps1 inside VM (this will take a while)..." 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" throw "Setup-TemplateVM.ps1 exited with code $result"
} }
# ── Success ─────────────────────────────────────────────────────────────── # ── 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 ""
Write-Host "============================================================" -ForegroundColor Green Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green Write-Host "============================================================" -ForegroundColor Green
Write-Host "" Write-Host ""
Write-Host " NOW DO THESE STEPS (manual):" Write-Host " MANUAL STEPS REQUIRED:"
Write-Host "" Write-Host ""
Write-Host " 1. Shut down the VM: Start -> Shut down" Write-Host " 1. Shut down the VM: Start -> Shut down"
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)" Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
Write-Host "" Write-Host ""
Write-Host " 2. Take snapshot:" Write-Host " 2. Take snapshot in VMware Workstation:"
Write-Host " VM -> Snapshot -> Take Snapshot" Write-Host " VM -> Snapshot -> Take Snapshot"
Write-Host " Name it EXACTLY: BaseClean" Write-Host " Name it EXACTLY: $SnapshotName"
Write-Host "" Write-Host ""
Write-Host " 3. Store CI credentials on this HOST:" if ($StoreCredential) {
Write-Host " (run in elevated PowerShell with CredentialManager module)" Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
Write-Host " New-StoredCredential -Target 'BuildVMGuest' ``" }
Write-Host " -UserName 'ci_build' -Password '<your-build-password>' ``" 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 '<your-build-password>' ``"
Write-Host " -Persist LocalMachine" Write-Host " -Persist LocalMachine"
}
Write-Host "" 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 ""
} }
finally { finally {
Remove-PSSession $session -ErrorAction SilentlyContinue Remove-PSSession $session -ErrorAction SilentlyContinue
+662 -189
View File
@@ -1,4 +1,4 @@
#Requires -RunAsAdministrator #Requires -RunAsAdministrator
#Requires -Version 5.1 #Requires -Version 5.1
<# <#
.SYNOPSIS .SYNOPSIS
@@ -8,13 +8,68 @@
This script is run ONE TIME inside the template VM before taking the This script is run ONE TIME inside the template VM before taking the
"BaseClean" snapshot. After the snapshot is taken, never modify the VM. "BaseClean" snapshot. After the snapshot is taken, never modify the VM.
Installs and configures: Installs and configures (each step followed by Assert-Step validation):
- Windows Updates (all available, via COM API — no external module needed) Step 1 — Firewall: all profiles disabled first — removes any block on subsequent steps
- WinRM with basic authentication (for PSSession from host) Validation: Domain/Private/Public all Enabled=False
- A dedicated local build user account Step 2 — Defender & Antimalware disabled — before WinRM and Windows Update
- Visual Studio Build Tools 2026 (MSBuild, MSVC, .NET desktop targets) Set-MpPreference + GPO registry keys; exclusions C:\CI, C:\dotnet, C:\Python
- .NET SDK 10.x Validation: 6 GPO keys + MpPreference (if WinDefend service present)
- Firewall rules for WinRM 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 NOTE: Git is NOT installed. The host clones the repository and copies
the source tree into the VM via WinRM (Option B isolation model). the source tree into the VM via WinRM (Option B isolation model).
@@ -27,13 +82,27 @@
║ for pip/nuget downloads at runtime). ║ 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) 1. Shut down the VM (Start → Shut down)
The VM stays on VMnet8 (NAT) — internet access is required for builds. The VM stays on VMnet8 (NAT) — internet access is required for builds.
2. In VMware Workstation: VM → Snapshot → Take Snapshot 2. In VMware Workstation: VM → Snapshot → Take Snapshot
Name it exactly: BaseClean Name it exactly: BaseClean
3. Power off the VM and leave it powered off permanently 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 .PARAMETER SkipWindowsUpdate
Skip the Windows Update step (useful if updates were already applied). 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 Invoke via Prepare-TemplateSetup.ps1 from the HOST (recommended), or
run manually from an elevated PowerShell session INSIDE the VM: run manually from an elevated PowerShell session INSIDE the VM:
Set-ExecutionPolicy Bypass -Scope Process -Force Set-ExecutionPolicy Bypass -Scope Process -Force
.\Setup-TemplateVM.ps1 .\Setup-TemplateVM.ps1 -BuildPassword 'YourPassword'
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -61,7 +130,10 @@ param(
[string] $PythonVersion = '3.13.3', [string] $PythonVersion = '3.13.3',
# Skip Windows Update (useful when updates already applied or no internet) # 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 Set-StrictMode -Version Latest
@@ -72,7 +144,382 @@ function Write-Step {
Write-Host "`n=== $Message ===" -ForegroundColor Cyan 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 # 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 # 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). # the actual download+install inside a Scheduled Task (SYSTEM account, full token).
@@ -93,14 +540,18 @@ $ErrorActionPreference = 'Stop'
try { try {
$s = New-Object -ComObject Microsoft.Update.Session $s = New-Object -ComObject Microsoft.Update.Session
$q = $s.CreateUpdateSearcher() $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 $count = $found.Updates.Count
if ($count -eq 0) { if ($count -eq 0) {
"0" | Set-Content C:\CI\wu_result.txt "0" | Set-Content C:\CI\wu_result.txt
"False" | Set-Content C:\CI\wu_reboot.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 { } 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 = $s.CreateUpdateDownloader()
$dl.Updates = $found.Updates $dl.Updates = $found.Updates
$dl.Download() | Out-Null $dl.Download() | Out-Null
@@ -120,6 +571,38 @@ try {
'@ '@
$wuWorker | Set-Content -Path $wuWorkerPath -Encoding UTF8 $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 any leftover result files from a previous run
Remove-Item $wuResultPath, $wuRebootPath -ErrorAction SilentlyContinue 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 ───────────────────────────────────────────────────────────── # WU has completed (or was skipped via -SkipWindowsUpdate). Disable now so that
Write-Step "Configuring WinRM" # 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. # Stop + disable Update Orchestrator Service (UsoSvc) — WU client in Server 2025
# Migrate to HTTPS/5986 with a self-signed cert for hardened environments. Stop-Service -Name UsoSvc -Force -ErrorAction SilentlyContinue
winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
# Increase shell memory and timeout limits for long builds Write-Host "wuauserv and UsoSvc set to Disabled."
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 # GPO registry keys — survive service self-healing and future WU self-repair attempts
Set-Service -Name WinRM -StartupType Automatic 3>$null $wuKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
Start-Service WinRM 3>$null $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-Host "Windows Update GPO policy keys written."
Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM"
# Disable all firewall profiles entirely. This VM lives on VMnet8 NAT behind # Disable WU scheduled tasks (best-effort — some may not exist on every SKU)
# the VMware NAT gateway with no inbound exposure from outside the host, so $wuTaskPath = '\Microsoft\Windows\WindowsUpdate\'
# full disable is acceptable and avoids profile-classification issues blocking foreach ($taskName in @('Scheduled Start', 'WakeTimer', 'Automatic App Update',
# WinRM/ICMP from the host. 'sih', 'sihboot', 'USO_UxBroker')) {
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False Disable-ScheduledTask -TaskPath $wuTaskPath -TaskName $taskName `
Write-Host "Windows Firewall disabled on all profiles." -ErrorAction SilentlyContinue | Out-Null
# ── 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."
} }
else { Write-Host "Windows Update scheduled tasks disabled (best-effort)."
# Convert to SecureString locally (inside the VM session) to avoid exposing
# the password as a process argument visible in audit log event 4688. # Validation
$secPwd = ConvertTo-SecureString $BuildPassword -AsPlainText -Force Assert-Step 'WUDisable' 'wuauserv StartType Disabled' {
try { (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled'
New-LocalUser -Name $BuildUsername -Password $secPwd `
-PasswordNeverExpires -Description 'CI build account' `
-ErrorAction Stop | Out-Null
} }
catch { Assert-Step 'WUDisable' 'NoAutoUpdate=1 GPO key' {
throw "Failed to create user '$BuildUsername': $_" (Get-ItemProperty -Path $wuAUKey -Name NoAutoUpdate -ErrorAction Stop).NoAutoUpdate -eq 1
} }
Write-Host "User '$BuildUsername' created." 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) # ── Step 7: Install .NET SDK ──────────────────────────────────────────────────
& 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 ─────────────────────────────────────────────────
Write-Step "Installing .NET SDK $DotNetSdkVersion" Write-Step "Installing .NET SDK $DotNetSdkVersion"
$dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue $dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue
@@ -362,7 +743,20 @@ else {
Write-Host ".NET SDK installed: $(dotnet --version)" 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" Write-Step "Installing Python $PythonVersion"
$pythonExe = 'C:\Python\python.exe' $pythonExe = 'C:\Python\python.exe'
@@ -398,7 +792,21 @@ else {
Write-Host "Python installed: $(python --version 2>&1)" 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" Write-Step "Installing Visual Studio Build Tools 2026"
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe' $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." 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" Write-Step "Verifying toolchain"
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
@@ -536,9 +959,9 @@ function Resolve-Tool {
$dotnetBin = Resolve-Tool '' 'dotnet' $dotnetBin = Resolve-Tool '' 'dotnet'
if ($dotnetBin) { if ($dotnetBin) {
$v = & $dotnetBin --version 2>&1 | Select-Object -First 1 $v = & $dotnetBin --version 2>&1 | Select-Object -First 1
Write-Host " [OK] dotnet: $v ($dotnetBin)" Write-Host " [OK] dotnet: $v ($dotnetBin)" -ForegroundColor Green
} else { } else {
Write-Warning " [FAIL] dotnet not found" Write-Host " [FAIL] dotnet not found" -ForegroundColor Red
$allOk = $false $allOk = $false
} }
@@ -546,9 +969,9 @@ if ($dotnetBin) {
$pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python' $pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python'
if ($pythonBin) { if ($pythonBin) {
$v = & $pythonBin --version 2>&1 | Select-Object -First 1 $v = & $pythonBin --version 2>&1 | Select-Object -First 1
Write-Host " [OK] python: $v ($pythonBin)" Write-Host " [OK] python: $v ($pythonBin)" -ForegroundColor Green
} else { } else {
Write-Warning " [FAIL] python not found" Write-Host " [FAIL] python not found" -ForegroundColor Red
$allOk = $false $allOk = $false
} }
@@ -556,17 +979,22 @@ if ($pythonBin) {
$msbuildBin = Resolve-Tool $msbuildPath 'msbuild' $msbuildBin = Resolve-Tool $msbuildPath 'msbuild'
if ($msbuildBin) { if ($msbuildBin) {
$v = & $msbuildBin -version 2>&1 | Select-Object -First 1 $v = & $msbuildBin -version 2>&1 | Select-Object -First 1
Write-Host " [OK] msbuild: $v ($msbuildBin)" Write-Host " [OK] msbuild: $v ($msbuildBin)" -ForegroundColor Green
} else { } 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 $allOk = $false
} }
if (-not $allOk) { 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 ─────────────────────────────────────────────────────────────────── # ── Cleanup ───────────────────────────────────────────────────────────────────
if ($SkipCleanup) {
Write-Host "`n[Setup] Skipping disk cleanup (-SkipCleanup switch set)." -ForegroundColor Yellow
}
else {
Write-Step "Cleaning up disk" Write-Step "Cleaning up disk"
# 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags # 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))." Write-Host "cleanmgr exited (code $($proc.ExitCode))."
# 2. Clear Windows Update cache # 2. Clear Windows Update cache
Write-Host "Stopping Windows Update service..." # Stop all services that might hold file locks inside SoftwareDistribution:
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue # wuauserv — Windows Update (already Disabled from Step 6b, stop is idempotent)
Remove-Item 'C:\Windows\SoftwareDistribution\Download\*' -Recurse -Force -ErrorAction SilentlyContinue # UsoSvc — Update Orchestrator (already Disabled from Step 6b)
Start-Service -Name wuauserv -ErrorAction SilentlyContinue # 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." Write-Host "Windows Update download cache cleared."
# 3. Clear CBS / component store logs # 3. Clear CBS / component store logs
@@ -635,25 +1075,58 @@ Write-Host "DISM exited (code $($dism.ExitCode))."
Write-Host "Disk cleanup complete." 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 ────────────────────────────────────────────────────────────────────── # ── Done ──────────────────────────────────────────────────────────────────────
Write-Host "" Write-Host ""
Write-Host "============================================================" -ForegroundColor Green Write-Host "Setup-TemplateVM.ps1 complete. All Assert-Step checks passed." -ForegroundColor Green
Write-Host " Template VM setup complete!" -ForegroundColor Green # Full next-steps banner is printed by Prepare-TemplateSetup.ps1 on the host.
Write-Host "============================================================" -ForegroundColor Green # If running this script standalone (manually inside the VM), follow the steps
Write-Host "" # in docs/WINDOWS-TEMPLATE-SETUP.md (shut down → snapshot BaseClean → power off).
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 '<your-build-password>' -Persist LocalMachine"
Write-Host " (Run in an elevated PowerShell with CredentialManager module)"
Write-Host "" Write-Host ""