34b33c5011
Test-WSMan in Windows PowerShell 5.1 does not support -SessionOption (WSManSessionOption),
causing "Cannot find a parameter matching the name 'SessionOption'" at runtime.
Prepare-WinBuild2025.ps1:
- Remove Test-WSMan connectivity check step entirely
- Open New-PSSession directly (handles -SkipCACheck via PSSessionOption)
- Wrap New-PSSession in try/catch with the same actionable error message
- Remove $wsmOpt (WSManSessionOption) — no longer needed
Wait-GuestWinRMReady: replace Test-WSMan probe with New-PSSession probe + Remove-PSSession
Wait-VMReady.ps1:
- Remove $wsmOpt entirely
- Replace Test-WSMan -UseSSL -SessionOption with Test-NetConnection -Port 5986
(TCP open on 5986 = HTTPS listener up = VM ready; credentials not available here)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
669 lines
33 KiB
PowerShell
669 lines
33 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
HOST-side script: delivers and runs Setup-WinBuild2025.ps1 inside the template VM.
|
|
|
|
.DESCRIPTION
|
|
Automates the template VM provisioning from the host machine:
|
|
1. Pre-flight validation: script presence, IP octet range, TCP/5986 reachable
|
|
2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) — restored on exit
|
|
3. Validates host WinRM TrustedHosts applied correctly
|
|
4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck — fails fast with actionable message
|
|
(PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe)
|
|
5. PSSession open — step 4 and 5 are now merged
|
|
6. Ensures C:\CI exists on guest; validates creation
|
|
7. Copies Setup-WinBuild2025.ps1 into the VM; validates file present + size match
|
|
8. Runs Setup-WinBuild2025.ps1 inside the VM with the given parameters
|
|
(Setup performs per-step validation internally — aborts on any failure)
|
|
9. Handles the reboot-required case (exit 3010) and optionally re-runs
|
|
10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command
|
|
(WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs)
|
|
11. Restores host TrustedHosts in finally block
|
|
|
|
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
|
|
The snapshot should only be taken after this script exits 0.
|
|
|
|
PREREQUISITES (do these manually before running this script):
|
|
a. VM NIC is set to VMnet8 (NAT) — the VM needs internet for downloads
|
|
b. Windows Server is installed and booted
|
|
c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run:
|
|
winrm quickconfig -q
|
|
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
|
d. You know the IP address the VM got from DHCP on VMnet8 (NAT)
|
|
(check via: ipconfig inside the VM, or VMware → VM → Settings →
|
|
Network Adapter → Advanced → MAC address, then check DHCP leases)
|
|
|
|
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
|
|
1. Shut down the VM
|
|
2. Take snapshot: VM → Snapshot → Take Snapshot → name: BaseClean
|
|
(VM stays on VMnet8 NAT — internet access is required for builds)
|
|
3. Store credentials on host (use the same password chosen during provisioning):
|
|
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
|
|
-Password '<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
|
|
IP address of the template VM on VMnet8 NAT.
|
|
Optional when -VMXPath is provided (IP is auto-detected via VMware Tools).
|
|
Required when -VMXPath is omitted.
|
|
Example: 192.168.79.130
|
|
|
|
.PARAMETER AdminUsername
|
|
Administrator username inside the VM. Default: Administrator
|
|
|
|
.PARAMETER AdminPassword
|
|
Administrator password inside the VM. If not supplied, prompts interactively.
|
|
|
|
.PARAMETER BuildPassword
|
|
Password to set for the ci_build user inside the VM.
|
|
Must not be empty — script prompts interactively if not supplied.
|
|
|
|
.PARAMETER BuildUsername
|
|
Local username for the CI build account inside the VM. Default: ci_build.
|
|
Passed through to Setup-WinBuild2025.ps1 and used in post-setup validation.
|
|
|
|
.PARAMETER DotNetSdkVersion
|
|
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
|
|
|
|
.PARAMETER SkipWindowsUpdate
|
|
Pass through to Setup-WinBuild2025.ps1 to skip the Windows Update step.
|
|
|
|
.PARAMETER SnapshotName
|
|
Name of the snapshot to create. Default: BaseClean (case-sensitive — used by New-BuildVM.ps1).
|
|
Only relevant when -TakeSnapshot is set.
|
|
|
|
.PARAMETER StoreCredential
|
|
After provisioning completes, store BuildUsername/BuildPassword in Windows
|
|
Credential Manager (target: CredentialTarget). Installs the CredentialManager
|
|
module (CurrentUser scope) automatically if not present.
|
|
Equivalent to running manually:
|
|
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
|
|
-Password '<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-WinBuild2025.ps1 `
|
|
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
|
|
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
|
|
|
# Skip Windows Update (already applied):
|
|
.\Prepare-WinBuild2025.ps1 `
|
|
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
|
|
-SkipWindowsUpdate -StoreCredential
|
|
|
|
# Explicit IP (manual start, no VMware Tools required for IP detection):
|
|
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.130 `
|
|
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
|
|
|
# Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress:
|
|
.\Prepare-WinBuild2025.ps1 `
|
|
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' -VMIPAddress 192.168.79.130 `
|
|
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
# Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot.
|
|
# Either -VMXPath or -VMIPAddress (or both) must be supplied.
|
|
[string] $VMXPath = '',
|
|
|
|
# Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via VMware Tools).
|
|
[string] $VMIPAddress = '',
|
|
|
|
[string] $AdminUsername = 'Administrator',
|
|
|
|
[string] $AdminPassword = '',
|
|
|
|
[string] $BuildPassword = '',
|
|
|
|
[string] $BuildUsername = 'ci_build',
|
|
|
|
[string] $DotNetSdkVersion = '10.0',
|
|
|
|
[switch] $SkipWindowsUpdate,
|
|
|
|
# Skip disk cleanup inside the VM (cleanmgr + DISM + cache clear) — speeds up re-runs
|
|
[switch] $SkipCleanup,
|
|
|
|
# Snapshot name (case-sensitive — must match GITEA_CI_SNAPSHOT_NAME in runner/config.yaml)
|
|
[string] $SnapshotName = 'BaseClean',
|
|
|
|
# Store BuildUsername/BuildPassword in Windows Credential Manager after provisioning
|
|
[switch] $StoreCredential,
|
|
|
|
# Credential Manager target name (must match what CI scripts use via Get-StoredCredential)
|
|
[string] $CredentialTarget = 'BuildVMGuest'
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# ── Locate vmrun.exe ──────────────────────────────────────────────────────────
|
|
$vmrunCandidates = @(
|
|
'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
|
'C:\Program Files\VMware\VMware Workstation\vmrun.exe'
|
|
)
|
|
$vmrunExe = $vmrunCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
|
if (-not $vmrunExe) {
|
|
$vmrunFound = Get-Command vmrun -ErrorAction SilentlyContinue
|
|
if ($vmrunFound) { $vmrunExe = $vmrunFound.Source }
|
|
}
|
|
if (-not $vmrunExe) { throw "[Prepare] vmrun.exe not found. Install VMware Workstation or add it to PATH." }
|
|
|
|
# ── Validate input: need VMXPath or VMIPAddress ───────────────────────────────
|
|
if ($VMXPath -eq '' -and $VMIPAddress -eq '') {
|
|
throw "[Prepare] Provide -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)."
|
|
}
|
|
|
|
# ── Power on VM and detect IP via VMXPath ────────────────────────────────────
|
|
$vmWasPoweredOn = $false
|
|
if ($VMXPath -ne '') {
|
|
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" }
|
|
|
|
# Check if already running
|
|
$runningList = @(& $vmrunExe list 2>&1 |
|
|
Where-Object { $_ -match '\.vmx$' } | ForEach-Object { $_.Trim() })
|
|
|
|
if ($runningList -notcontains $VMXPath) {
|
|
Write-Host "[Prepare] VM not running — starting: $VMXPath"
|
|
& $vmrunExe start $VMXPath
|
|
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start exited $LASTEXITCODE." }
|
|
$vmWasPoweredOn = $true
|
|
Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..."
|
|
}
|
|
else {
|
|
Write-Host "[Prepare] VM already running: $VMXPath"
|
|
}
|
|
|
|
# Auto-detect IP if not supplied
|
|
if ($VMIPAddress -eq '') {
|
|
Write-Host "[Prepare] Detecting guest IP via VMware Tools (getGuestIPAddress -wait)..."
|
|
$detectedIP = (& $vmrunExe getGuestIPAddress $VMXPath -wait 2>&1).Trim()
|
|
if ($detectedIP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
|
|
throw "[Prepare] Could not get guest IP. Got: '$detectedIP'. Ensure VMware Tools is installed."
|
|
}
|
|
$VMIPAddress = $detectedIP
|
|
Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green
|
|
}
|
|
}
|
|
|
|
function Assert-Step {
|
|
param(
|
|
[Parameter(Mandatory)] [string] $StepName,
|
|
[Parameter(Mandatory)] [string] $Description,
|
|
[Parameter(Mandatory)] [scriptblock] $Test
|
|
)
|
|
$ok = $false
|
|
try { $ok = [bool](& $Test) }
|
|
catch { throw "[Prepare/$StepName] Validation threw on '$Description': $_" }
|
|
if (-not $ok) {
|
|
throw "[Prepare/$StepName] Validation failed: $Description"
|
|
}
|
|
Write-Host " [OK] $Description" -ForegroundColor Green
|
|
}
|
|
|
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
|
|
# Pre-flight validation
|
|
Assert-Step 'PreFlight' 'script directory resolved' { Test-Path $scriptDir -PathType Container }
|
|
Assert-Step 'PreFlight' 'Setup-WinBuild2025.ps1 present alongside this script' {
|
|
Test-Path (Join-Path $scriptDir 'Setup-WinBuild2025.ps1') -PathType Leaf
|
|
}
|
|
Assert-Step 'PreFlight' "VMIPAddress octets in 0..255" {
|
|
$parts = $VMIPAddress.Split('.') | ForEach-Object { [int]$_ }
|
|
($parts.Count -eq 4) -and -not ($parts | Where-Object { $_ -lt 0 -or $_ -gt 255 })
|
|
}
|
|
|
|
# WinRM readiness — retry loop (longer timeout if VM was just powered on by this script)
|
|
$winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 }
|
|
Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5986 (timeout ${winrmTimeoutSec}s)..."
|
|
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
|
|
$winrmReady = $false
|
|
while ([datetime]::UtcNow -lt $winrmDeadline) {
|
|
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 `
|
|
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
|
|
$winrmReady = $true; break
|
|
}
|
|
Start-Sleep -Seconds 5
|
|
Write-Host " ... waiting for WinRM..."
|
|
}
|
|
Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady }
|
|
|
|
# Prompt for BuildPassword if not supplied — never use a hardcoded default.
|
|
if ($BuildPassword -eq '') {
|
|
Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:"
|
|
$secPwd = Read-Host -Prompt " BuildPassword" -AsSecureString
|
|
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
|
|
$BuildPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
|
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
|
}
|
|
|
|
# ── Build PSCredential ────────────────────────────────────────────────────────
|
|
if ($AdminPassword -eq '') {
|
|
Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):"
|
|
$credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)"
|
|
}
|
|
else {
|
|
$secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force
|
|
$credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd)
|
|
}
|
|
|
|
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
|
|
|
# ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ──────
|
|
# HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts
|
|
# needs appending so PS accepts the non-domain VM. Restored in the finally block.
|
|
$prevTrustedHosts = $null
|
|
try {
|
|
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
|
|
# Add VM IP to TrustedHosts if not already present
|
|
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
|
|
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
|
|
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
|
|
}
|
|
Write-Host "[Prepare] Host TrustedHosts configured."
|
|
Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" {
|
|
$th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
|
$th -eq '*' -or $th -like "*$VMIPAddress*"
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)."
|
|
Write-Warning "Run once in an elevated PowerShell on the HOST:"
|
|
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
|
|
Write-Warning "Then re-run this script."
|
|
exit 1
|
|
}
|
|
|
|
|
|
# ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ──
|
|
# New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert.
|
|
Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..."
|
|
$session = try {
|
|
New-PSSession `
|
|
-ComputerName $VMIPAddress `
|
|
-Credential $credential `
|
|
-SessionOption $sessionOptions `
|
|
-UseSSL `
|
|
-Port 5986 `
|
|
-Authentication Basic `
|
|
-ErrorAction Stop
|
|
} catch {
|
|
Write-Error @"
|
|
[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
|
|
|
|
Ensure:
|
|
- The VM is running and on VMnet8 (NAT) with internet access
|
|
- WinRM HTTPS is enabled inside the VM — Deploy-WinBuild2025.ps1 post-install.ps1
|
|
must have run successfully (creates self-signed cert + HTTPS listener on 5986)
|
|
- Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest)
|
|
- The IP is correct (check ipconfig inside the VM)
|
|
|
|
Error: $_
|
|
"@
|
|
exit 1
|
|
}
|
|
|
|
try {
|
|
# ── Copy Setup-WinBuild2025.ps1 into the VM ───────────────────────────────
|
|
$setupScript = Join-Path $scriptDir 'Setup-WinBuild2025.ps1'
|
|
$guestScriptPath = 'C:\CI\Setup-WinBuild2025.ps1'
|
|
|
|
Write-Host "[Prepare] Ensuring C:\CI exists on guest..."
|
|
Invoke-Command -Session $session -ScriptBlock {
|
|
if (-not (Test-Path 'C:\CI')) {
|
|
New-Item -ItemType Directory -Path 'C:\CI' -Force | Out-Null
|
|
}
|
|
}
|
|
Assert-Step 'GuestPrep' 'C:\CI exists on guest' {
|
|
Invoke-Command -Session $session -ScriptBlock { Test-Path 'C:\CI' -PathType Container }
|
|
}
|
|
|
|
Write-Host "[Prepare] Copying Setup-WinBuild2025.ps1 -> guest $guestScriptPath (UTF-8 BOM)..."
|
|
# Do NOT use Copy-Item -ToSession: it binary-copies the file.
|
|
# If the source file has no UTF-8 BOM, PowerShell 5.1 on the guest reads it as
|
|
# Windows-1252, misinterpreting multi-byte UTF-8 sequences (e.g. em dash U+2014
|
|
# encodes as E2 80 94; byte 0x94 in Win-1252 = curly right-quote, which PS 5.1
|
|
# treats as a string terminator → parse errors throughout the script).
|
|
#
|
|
# Fix: read content on host as Unicode string, transmit via WinRM (XML/Unicode),
|
|
# write on guest with explicit UTF-8 BOM via System.Text.UTF8Encoding($true).
|
|
# PS 5.1 detects the BOM and reads the file as UTF-8 correctly.
|
|
$scriptContent = [System.IO.File]::ReadAllText($setupScript, [System.Text.Encoding]::UTF8)
|
|
Invoke-Command -Session $session -ScriptBlock {
|
|
param($content, $path)
|
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true) # $true = emit BOM
|
|
[System.IO.File]::WriteAllText($path, $content, $utf8Bom)
|
|
} -ArgumentList $scriptContent, $guestScriptPath
|
|
|
|
# Validation — file present and large enough (BOM transfer changes byte count vs host)
|
|
$hostSize = (Get-Item $setupScript).Length
|
|
Assert-Step 'GuestPrep' "guest script present at $guestScriptPath" {
|
|
Invoke-Command -Session $session -ScriptBlock { param($p) Test-Path $p -PathType Leaf } -ArgumentList $guestScriptPath
|
|
}
|
|
Assert-Step 'GuestPrep' "guest script size reasonable (host=$hostSize bytes ±6 for BOM)" {
|
|
$remoteSize = Invoke-Command -Session $session -ScriptBlock {
|
|
param($p) (Get-Item $p -ErrorAction SilentlyContinue).Length
|
|
} -ArgumentList $guestScriptPath
|
|
# UTF-8 BOM adds 3 bytes; allow small variance
|
|
[int64]$remoteSize -ge ([int64]$hostSize - 6) -and [int64]$remoteSize -le ([int64]$hostSize + 6)
|
|
}
|
|
|
|
# ── Build argument list for Setup-WinBuild2025.ps1 ───────────────────────
|
|
$setupArgs = @{
|
|
BuildPassword = $BuildPassword
|
|
BuildUsername = $BuildUsername
|
|
DotNetSdkVersion = $DotNetSdkVersion
|
|
AdminPassword = $AdminPassword
|
|
}
|
|
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
|
|
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
|
|
|
|
# ── Run Setup-WinBuild2025.ps1 inside the VM ──────────────────────────────
|
|
# Setup may return exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) when Windows
|
|
# Update applied patches that need a reboot. We loop: reboot guest, wait for
|
|
# WinRM, reopen session, re-invoke Setup. Steps 1-5 are idempotent (~30 s);
|
|
# Step 6 picks up where the previous run left off (no leftover updates →
|
|
# exit 0). Hard cap on iterations to avoid an infinite loop.
|
|
$maxWuIterations = 10
|
|
$rebootDeadlineMin = 20
|
|
|
|
function Invoke-GuestSetup {
|
|
param([Parameter(Mandatory)] $Session,
|
|
[Parameter(Mandatory)] [string] $ScriptPath,
|
|
[Parameter(Mandatory)] [hashtable] $ScriptArgs)
|
|
Invoke-Command -Session $Session -ScriptBlock {
|
|
param($scriptPath, $scriptArgs)
|
|
Set-ExecutionPolicy Bypass -Scope Process -Force
|
|
$exitCode = 0
|
|
try {
|
|
& $scriptPath @scriptArgs
|
|
$exitCode = $LASTEXITCODE
|
|
if ($null -eq $exitCode) { $exitCode = 0 }
|
|
} catch {
|
|
Write-Error $_
|
|
$exitCode = 1
|
|
}
|
|
return $exitCode
|
|
} -ArgumentList $ScriptPath, $ScriptArgs
|
|
}
|
|
|
|
function Wait-GuestWinRMReady {
|
|
param([Parameter(Mandatory)] [string] $IP,
|
|
[Parameter(Mandatory)] $Cred,
|
|
[Parameter(Mandatory)] $SessionOptions,
|
|
[int] $TimeoutMin = 20)
|
|
$deadline = (Get-Date).AddMinutes($TimeoutMin)
|
|
while ((Get-Date) -lt $deadline) {
|
|
Start-Sleep -Seconds 10
|
|
try {
|
|
# Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe.
|
|
$probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL `
|
|
-SessionOption $SessionOptions -Credential $Cred `
|
|
-Authentication Basic -ErrorAction Stop
|
|
Remove-PSSession $probe -ErrorAction SilentlyContinue
|
|
return $true
|
|
} catch { }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
Write-Host "[Prepare] Running Setup-WinBuild2025.ps1 inside VM (this will take a while)..."
|
|
|
|
for ($iter = 1; $iter -le $maxWuIterations; $iter++) {
|
|
Write-Host "[Prepare] Setup iteration $iter/$maxWuIterations"
|
|
Write-Host "[Prepare] Output from guest:"
|
|
Write-Host "------------------------------------------------------------"
|
|
|
|
$result = $null
|
|
try {
|
|
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
|
|
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
|
|
# VM rebooted mid-update (WU triggered OS restart before Setup could return 3010).
|
|
# Treat as reboot-required: close dead session, fall through to the reboot-wait block.
|
|
Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..."
|
|
Remove-PSSession $session -ErrorAction SilentlyContinue
|
|
$session = $null
|
|
$result = 3010
|
|
}
|
|
|
|
Write-Host "------------------------------------------------------------"
|
|
Write-Host "[Prepare] Iteration $iter exit code: $result"
|
|
|
|
if ($result -eq 0) { break }
|
|
|
|
if ($result -ne 3010) {
|
|
throw "Setup-WinBuild2025.ps1 exited with code $result"
|
|
}
|
|
|
|
# exit 3010 (or transport error treated as 3010) → WU needs reboot.
|
|
# Reboot guest if session still alive, wait for WinRM, reopen session, loop.
|
|
if ($iter -eq $maxWuIterations) {
|
|
throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)"
|
|
}
|
|
|
|
Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..."
|
|
if ($session) {
|
|
try {
|
|
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue
|
|
} catch { }
|
|
Remove-PSSession $session -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
Start-Sleep -Seconds 30 # let WinRM tear down before polling
|
|
Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..."
|
|
if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential `
|
|
-SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) {
|
|
throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot"
|
|
}
|
|
Write-Host "[Prepare] Guest WinRM ready, reopening session..."
|
|
$session = New-PSSession -ComputerName $VMIPAddress -Credential $credential `
|
|
-SessionOption $sessionOptions -UseSSL -Port 5986 `
|
|
-Authentication Basic -ErrorAction Stop
|
|
}
|
|
|
|
# ── Post-setup remote validation ──────────────────────────────────────────
|
|
Write-Host "[Prepare] Running post-setup validation against guest..."
|
|
$guestState = Invoke-Command -Session $session -ScriptBlock {
|
|
param($BuildUser)
|
|
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
|
|
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' +
|
|
[System.Environment]::GetEnvironmentVariable('PATH','User')
|
|
[PSCustomObject]@{
|
|
WinRMRunning = (Get-Service WinRM -ErrorAction SilentlyContinue).Status -eq 'Running'
|
|
UserExists = [bool](Get-LocalUser -Name $BuildUser -ErrorAction SilentlyContinue)
|
|
UserIsAdmin = [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUser)
|
|
UACDisabled = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA -eq 0
|
|
FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true)
|
|
DotNetVersion = (Get-Command dotnet -ErrorAction SilentlyContinue) | ForEach-Object { (& $_.Source --version 2>&1) }
|
|
PythonExists = Test-Path 'C:\Python\python.exe' -PathType Leaf
|
|
MSBuildExists = Test-Path $msbuildPath -PathType Leaf
|
|
CIDirsPresent = (Test-Path 'C:\CI\build') -and (Test-Path 'C:\CI\output') -and (Test-Path 'C:\CI\scripts')
|
|
}
|
|
} -ArgumentList $BuildUsername
|
|
|
|
Assert-Step 'PostSetup' 'guest WinRM Running' { $guestState.WinRMRunning }
|
|
Assert-Step 'PostSetup' "guest user '$BuildUsername' exists" { $guestState.UserExists }
|
|
Assert-Step 'PostSetup' "guest user '$BuildUsername' is admin" { $guestState.UserIsAdmin }
|
|
Assert-Step 'PostSetup' 'guest UAC disabled' { $guestState.UACDisabled }
|
|
Assert-Step 'PostSetup' 'guest firewall disabled' { $guestState.FirewallOff }
|
|
Assert-Step 'PostSetup' 'guest .NET SDK installed' { [bool]$guestState.DotNetVersion }
|
|
Assert-Step 'PostSetup' 'guest Python installed' { $guestState.PythonExists }
|
|
Assert-Step 'PostSetup' 'guest MSBuild present' { $guestState.MSBuildExists }
|
|
Assert-Step 'PostSetup' 'guest C:\CI\{build,output,scripts} present' { $guestState.CIDirsPresent }
|
|
|
|
Write-Host "[Prepare] All post-setup validations passed (.NET $($guestState.DotNetVersion))." -ForegroundColor Green
|
|
|
|
# ── Store credentials in Windows Credential Manager (optional) ────────────
|
|
if ($StoreCredential) {
|
|
Write-Host "[Prepare] Storing CI credentials in Windows Credential Manager (target: '$CredentialTarget')..."
|
|
if (-not (Get-Module -ListAvailable -Name CredentialManager)) {
|
|
Write-Host "[Prepare] CredentialManager module not found — installing (CurrentUser scope)..."
|
|
Install-Module -Name CredentialManager -Scope CurrentUser -Force -ErrorAction Stop
|
|
}
|
|
Import-Module CredentialManager -ErrorAction Stop
|
|
New-StoredCredential `
|
|
-Target $CredentialTarget `
|
|
-UserName $BuildUsername `
|
|
-Password $BuildPassword `
|
|
-Persist LocalMachine `
|
|
-ErrorAction Stop | Out-Null
|
|
Write-Host "[Prepare] Credentials stored: target='$CredentialTarget' user='$BuildUsername'." -ForegroundColor Green
|
|
}
|
|
|
|
Write-Host "[Prepare] All validations passed." -ForegroundColor Green
|
|
|
|
# ── Shutdown + snapshot prompt ─────────────────────────────────────────────
|
|
# vmrun.exe already located at script start ($vmrunExe)
|
|
|
|
# Auto-discover VMX path via 'vmrun list' while VM is still running (if not known)
|
|
if ($VMXPath -eq '') {
|
|
Write-Host "[Prepare] Discovering VMX path from running VMs..."
|
|
$runningVMs = @(& $vmrunExe list 2>&1 |
|
|
Where-Object { $_ -match '\.vmx$' } |
|
|
ForEach-Object { $_.Trim() })
|
|
|
|
if ($runningVMs.Count -eq 0) {
|
|
throw "[Prepare] No running VMs found via 'vmrun list'. Is the template VM still up?"
|
|
}
|
|
elseif ($runningVMs.Count -eq 1) {
|
|
$VMXPath = $runningVMs[0]
|
|
Write-Host "[Prepare] VMX auto-detected: $VMXPath" -ForegroundColor Green
|
|
}
|
|
else {
|
|
# Multiple VMs — prefer one under \CI\Templates\
|
|
$ciVMs = @($runningVMs | Where-Object { $_ -like '*\CI\Templates\*' })
|
|
if ($ciVMs.Count -eq 1) {
|
|
$VMXPath = $ciVMs[0]
|
|
Write-Host "[Prepare] VMX auto-detected (CI template): $VMXPath" -ForegroundColor Green
|
|
}
|
|
else {
|
|
Write-Host "[Prepare] Multiple VMs running — select one:"
|
|
for ($i = 0; $i -lt $runningVMs.Count; $i++) {
|
|
Write-Host " [$($i+1)] $($runningVMs[$i])"
|
|
}
|
|
$idx = [int](Read-Host "[Prepare] Enter number") - 1
|
|
if ($idx -lt 0 -or $idx -ge $runningVMs.Count) { throw "[Prepare] Invalid selection." }
|
|
$VMXPath = $runningVMs[$idx]
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found on disk: '$VMXPath'" }
|
|
|
|
# Prompt
|
|
Write-Host "------------------------------------------------------------"
|
|
Write-Host ""
|
|
$choice = Read-Host "[Prepare] Shut down VM and create snapshot '$SnapshotName'? [Y/N]"
|
|
if ($choice -match '^[Yy]') {
|
|
|
|
# Send graceful shutdown
|
|
Write-Host "[Prepare] Sending graceful shutdown to VM..."
|
|
try {
|
|
Invoke-Command -Session $session -ScriptBlock { Stop-Computer -Force } -ErrorAction Stop
|
|
Write-Host "[Prepare] Shutdown command sent." -ForegroundColor Green
|
|
}
|
|
catch {
|
|
Write-Warning "[Prepare] Shutdown command failed (VM may have already shut down): $_"
|
|
}
|
|
|
|
# Close session — VM is shutting down, no further WinRM needed
|
|
Remove-PSSession $session -ErrorAction SilentlyContinue
|
|
|
|
# Wait for VM to power off (poll vmrun list)
|
|
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
|
|
$shutdownDeadline = [datetime]::UtcNow.AddSeconds(120)
|
|
$poweredOff = $false
|
|
while ([datetime]::UtcNow -lt $shutdownDeadline) {
|
|
$runningList = & $vmrunExe list 2>&1 | Out-String
|
|
if ($runningList -notmatch [regex]::Escape($VMXPath)) { $poweredOff = $true; break }
|
|
Start-Sleep -Seconds 5
|
|
Write-Host " ... waiting for shutdown..."
|
|
}
|
|
if (-not $poweredOff) { throw "[Prepare] VM did not power off within 120 s. Check VMware Workstation." }
|
|
|
|
# Check for existing snapshot with same name
|
|
$existingSnaps = & $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String
|
|
$doCreate = $true
|
|
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
|
|
Write-Warning "[Prepare] Snapshot '$SnapshotName' already exists."
|
|
$delChoice = Read-Host "[Prepare] Delete existing snapshot and recreate? [Y/N]"
|
|
if ($delChoice -match '^[Yy]') {
|
|
Write-Host "[Prepare] Deleting existing snapshot '$SnapshotName'..."
|
|
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
|
|
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun deleteSnapshot exited $LASTEXITCODE." }
|
|
Write-Host "[Prepare] Existing snapshot deleted." -ForegroundColor Green
|
|
}
|
|
else {
|
|
Write-Host "[Prepare] Keeping existing snapshot — skipping creation." -ForegroundColor Yellow
|
|
$doCreate = $false
|
|
}
|
|
}
|
|
|
|
# Create snapshot
|
|
if ($doCreate) {
|
|
Write-Host "[Prepare] Creating snapshot '$SnapshotName'..." -ForegroundColor Green
|
|
& $vmrunExe -T ws snapshot $VMXPath $SnapshotName
|
|
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun snapshot exited $LASTEXITCODE." }
|
|
Write-Host "[Prepare] Snapshot '$SnapshotName' created successfully." -ForegroundColor Green
|
|
}
|
|
Write-Host ""
|
|
Write-Host "============================================================" -ForegroundColor Green
|
|
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
|
|
Write-Host "============================================================" -ForegroundColor Green
|
|
if ($StoreCredential) {
|
|
Write-Host " [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
|
|
}
|
|
Write-Host ""
|
|
}
|
|
else {
|
|
Write-Host ""
|
|
Write-Host "============================================================" -ForegroundColor Green
|
|
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
|
|
Write-Host "============================================================" -ForegroundColor Green
|
|
Write-Host ""
|
|
Write-Host " MANUAL STEPS REQUIRED:"
|
|
Write-Host ""
|
|
Write-Host " 1. Shut down the VM: Start -> Shut down"
|
|
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
|
|
Write-Host ""
|
|
Write-Host " 2. Take snapshot in VMware Workstation:"
|
|
Write-Host " VM -> Snapshot -> Take Snapshot"
|
|
Write-Host " Name it EXACTLY: $SnapshotName"
|
|
Write-Host ""
|
|
if ($StoreCredential) {
|
|
Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
|
|
}
|
|
else {
|
|
Write-Host " 3. Store CI credentials on this HOST (run in elevated PowerShell):"
|
|
Write-Host " .\Prepare-WinBuild2025.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 ""
|
|
}
|
|
}
|
|
finally {
|
|
Remove-PSSession $session -ErrorAction SilentlyContinue
|
|
|
|
# Restore host TrustedHosts to its original value
|
|
if ($null -ne $prevTrustedHosts) {
|
|
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Write-Host "[Prepare] Host TrustedHosts restored."
|
|
}
|