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:
@@ -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
|
||||||
|
|||||||
+664
-191
@@ -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' `
|
Assert-Step 'WUDisable' 'NoAutoUpdate=1 GPO key' {
|
||||||
-ErrorAction Stop | Out-Null
|
(Get-ItemProperty -Path $wuAUKey -Name NoAutoUpdate -ErrorAction Stop).NoAutoUpdate -eq 1
|
||||||
}
|
}
|
||||||
catch {
|
Assert-Step 'WUDisable' 'DisableWindowsUpdateAccess=1 GPO key' {
|
||||||
throw "Failed to create user '$BuildUsername': $_"
|
(Get-ItemProperty -Path $wuKey -Name DisableWindowsUpdateAccess -ErrorAction Stop).DisableWindowsUpdateAccess -eq 1
|
||||||
}
|
|
||||||
Write-Host "User '$BuildUsername' created."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user