Files
local-ci-cd-system/template/Deploy-WinBuild2022.ps1
T

1118 lines
58 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
Deploys an unattended Windows Server 2022 VM on VMware Workstation
and installs VMware Tools automatically.
.DESCRIPTION
Host-side orchestrator. Produces a ready-to-use template VM:
Step 1 — Validate prerequisites (vmrun, vmware-vdiskmanager, ISOs)
Step 2 — Render autounattend.xml from template + post-install.ps1
(placeholders substituted with parameter values)
Step 3 — Build autounattend ISO (autounattend.xml + post-install.ps1)
via IMAPI2FS COM (built-in Windows, zero deps)
Step 4 — Create the VMDK (80 GB single-file thin) via vmware-vdiskmanager
Step 5 — Generate the .vmx (UEFI no SecureBoot, NVMe disk, e1000e NIC,
two CD-ROMs: Win install + autounattend; Tools bundled inside autounattend)
Step 6 — Power on the VM (vmrun start gui/nogui — async to avoid Workstation hang)
Step 7 — Wait for the install_complete.flag file inside the guest
(created by post-install.ps1 once Tools install finishes)
Step 8 — Graceful soft-stop, remove CD-ROM controller + USB from VMX, swap NIC e1000e → vmxnet3
Step 9 — Power on, wait for guest IP via vmrun (vmxnet3 verified)
Step 10 — Take snapshot named $SnapshotName
Final — Print summary (VM path, IP, snapshot name)
NIC strategy:
Boot install with e1000e (broad OOB Windows compatibility). VMware Tools
install brings the vmxnet3 driver. Post-Tools, the host swaps the .vmx
to vmxnet3 and reboots. This avoids driver injection in WinPE.
Disk layout (UEFI/GPT, 80 GB):
Part 1: EFI 100 MB FAT32 S:
Part 2: MSR 16 MB
Part 3: Primary rest NTFS C: ← Windows installs here
No Recovery partition (per user choice).
Hardening done by post-install.ps1 inside the guest:
- UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1)
- Defender real-time protection off + DisableAntiSpyware=1 GPO
- DiagTrack/Telemetry off
- Windows Update GPO locks (NoAutoUpdate=1, DisableWindowsUpdateAccess=1);
services remain Manual — Install-CIToolchain-WinBuild2022 Step 6b permanently disables
them after applying updates (§7.2 Strategy B)
- Windows Firewall all profiles disabled (VMnet8 NAT lab; Setup Step 1 validates)
- RDP enabled (no NLA, lab only) + firewall rules for RDP/ICMP/WinRM-HTTPS
- WinRM HTTPS 5986 only (self-signed cert), Basic auth, AllowUnencrypted=false,
TrustedHosts=*, MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=25
- ICMPv4 inbound allowed
- Lock screen disabled, Server Manager (policy + HKLM + task + HKCU + Default hive),
DisableCAD (Policies\System + Winlogon), OOBE/telemetry suppressed;
Install-CIToolchain-WinBuild2022 Step 5b/5c validate all of these
- Taskbar search bar hidden, Task View button hidden
- Windows Terminal set as default terminal (DelegationConsole/Terminal HKCU + Default hive)
- Azure Arc auto-start, scheduled tasks, and himds service disabled
- VMware Tools installed (Complete) from Tools ISO
- install_complete.flag dropped in C:\Windows\Temp
.PARAMETER WinISO
Path to the Windows Server 2022 install ISO.
.PARAMETER ToolsISO
Path to the VMware Tools ISO (windows.iso). Contents are extracted and bundled
inside the autounattend ISO at build time (Step 3); not mounted as a separate CD-ROM.
.PARAMETER VMXPath
Full path of the .vmx file to create. The folder is created if missing.
.PARAMETER VMName
Display name in the Workstation library. Defaults to the .vmx file basename.
.PARAMETER ProductKey
Windows Server 2022 product key to embed in autounattend.
.PARAMETER ComputerName
Hostname. Max 15 chars, no spaces.
.PARAMETER AdminUser
.PARAMETER AdminPassword
Local Administrator account credentials.
.PARAMETER ImageIndex
Index of the install.wim edition to deploy. Default: 2 (Server 2022
Standard Desktop Experience on the VOL ISO). Verify with:
dism /Get-WimInfo /WimFile:<isomount>\sources\install.wim
.PARAMETER DiskSizeGB
Virtual disk size in GiB. Default: 80.
.PARAMETER MemoryMB
Guest RAM in MiB. Default: 6144 (6 GB).
.PARAMETER VCPUCount
Total vCPU. Default: 2.
.PARAMETER CoresPerSocket
Cores per socket. Default: 2 (=> 1 socket × 2 cores).
.PARAMETER LocaleOS
OS UI / system locale. Default: en-US.
.PARAMETER LocaleUser
User locale. Default: it-IT.
.PARAMETER InputKeyboard
Keyboard layout id. Default: 0410:00000410 (Italian).
.PARAMETER TimeZone
Windows time zone id. Default: W. Europe Standard Time.
.PARAMETER SnapshotName
Snapshot name taken at the end. Default: PostInstall.
.PARAMETER VMwareWorkstationDir
Override Workstation install dir if not at the default location.
.PARAMETER GuestPollSeconds
Seconds between polls when waiting for install_complete.flag. Default: 30.
.PARAMETER GuestPollMaxMinutes
Hard timeout waiting for install_complete.flag. Default: 90.
.NOTES
Run from elevated PowerShell on the host. The VM IP detection (Step 9)
requires VMware Tools to be running in the guest — guaranteed because
Step 7 waited for the install_complete.flag dropped after Tools install.
#>
[CmdletBinding()]
param(
# ── ISOs ──
[string] $WinISO = 'F:\CI\ISO\en-us_windows_server_2022_updated_april_2026_x64_dvd_a4d60e24.iso',
[string] $ToolsISO = 'F:\CI\ISO\VMware-tools-windows.iso',
# Cache path for the rebuilt no-prompt Windows ISO. Reused across runs when
# newer than the source. Empty string => rebuild every run inside the VM dir.
[string] $WinISONoPromptPath = '',
# ── VM identity ──
[string] $VMXPath = 'F:\CI\Templates\WinBuild2022\WinBuild2022.vmx',
[string] $VMName = 'WinBuild2022',
# ── Windows install ──
[string] $ProductKey = 'VDYBN-27WPP-V4HQT-9VMD4-VMK7H',
[string] $ComputerName = 'WINBUILD-2022',
[string] $AdminUser = 'Administrator',
[string] $AdminPassword = 'WinBuild!',
[int] $ImageIndex = 2,
# ── Hardware ──
[int] $DiskSizeGB = 80,
[int] $MemoryMB = 6144,
[int] $VCPUCount = 4,
[int] $CoresPerSocket = 2,
# ── Locale ──
[string] $LocaleOS = 'en-US',
[string] $LocaleUser = 'it-IT',
[string] $InputKeyboard = '0410:00000410',
[string] $TimeZone = 'W. Europe Standard Time',
# ── Finalization ──
[string] $SnapshotName = 'PostInstall',
# ── Tooling ──
[string] $VMwareWorkstationDir = 'C:\Program Files (x86)\VMware\VMware Workstation',
# ── Polling ──
[int] $GuestPollSeconds = 30,
[int] $GuestPollMaxMinutes = 90,
# When set, vmrun powers on the VM with the Workstation GUI visible.
# Default: headless (nogui) for unattended pipelines.
[switch] $ShowGui,
# Skip straight to this step number. All derived paths are still computed;
# artifacts from skipped steps must already exist on disk.
# Steps: 1=preflight 2=render XML 3=autounattend ISO 4=VMDK+noPromptISO
# 5=VMX 6=power on 7=wait flag 8=stop+NIC 9=power on vmxnet3 10=snapshot
[ValidateRange(1,10)]
[int] $StartFromStep = 1
)
$vmrunUiMode = if ($ShowGui) { 'gui' } else { 'nogui' }
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
function Write-Step {
param([string] $Message, [int] $Step = 0)
$skipped = $Step -gt 0 -and $script:StartFromStep -gt $Step
if ($skipped) {
Write-Host "`n=== $Message === [SKIP]" -ForegroundColor DarkGray
} else {
Write-Host "`n=== $Message ===" -ForegroundColor Cyan
}
}
function Assert-Step {
param(
[Parameter(Mandatory)] [string] $StepName,
[Parameter(Mandatory)] [string] $Description,
[Parameter(Mandatory)] [scriptblock] $Test
)
$ok = $false
try { $ok = [bool](& $Test) }
catch { throw "[$StepName] Validation threw on '$Description': $_" }
if (-not $ok) { throw "[$StepName] Validation failed: $Description" }
Write-Host " [OK] $Description" -ForegroundColor Green
}
# IMAPI2FS-based ISO writer. No external deps. Inline C# bridges IStream → file.
$isoWriterCs = @"
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
public class IsoStreamWriter2 {
[DllImport("shlwapi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)]
static extern int SHCreateStreamOnFileEx(string pszFile, uint grfMode, uint dwAttributes, bool fCreate, IntPtr pstmTemplate, out IStream ppstm);
public static IStream OpenFileAsStream(string path) {
IStream s;
// STGM_READ | STGM_SHARE_DENY_WRITE = 0x00000020
int hr = SHCreateStreamOnFileEx(path, 0x20, 0, false, IntPtr.Zero, out s);
if (hr != 0) throw new Exception("SHCreateStreamOnFileEx 0x" + hr.ToString("X"));
return s;
}
public static void Save(object streamObj, string path) {
IStream istream = streamObj as IStream;
if (istream == null) {
// Fallback: QueryInterface for IStream IID via Marshal.
IntPtr unk = Marshal.GetIUnknownForObject(streamObj);
try {
Guid iid = new Guid("0000000C-0000-0000-C000-000000000046");
IntPtr pStream;
int hr = Marshal.QueryInterface(unk, ref iid, out pStream);
if (hr != 0) throw new InvalidCastException("QueryInterface(IStream) failed: 0x" + hr.ToString("X"));
try {
istream = (IStream)Marshal.GetObjectForIUnknown(pStream);
} finally {
Marshal.Release(pStream);
}
} finally {
Marshal.Release(unk);
}
}
using (FileStream fs = File.Open(path, FileMode.Create, FileAccess.Write)) {
byte[] buf = new byte[2048 * 16];
IntPtr pcb = Marshal.AllocHGlobal(8);
try {
int n;
do {
istream.Read(buf, buf.Length, pcb);
n = Marshal.ReadInt32(pcb);
if (n > 0) fs.Write(buf, 0, n);
} while (n > 0);
} finally {
Marshal.FreeHGlobal(pcb);
}
}
}
}
"@
if (-not ([System.Management.Automation.PSTypeName]'IsoStreamWriter2').Type) {
Add-Type -TypeDefinition $isoWriterCs -Language CSharp
}
function New-IsoFromFolder {
param(
[Parameter(Mandatory)] [string] $SourceFolder,
[Parameter(Mandatory)] [string] $IsoPath,
[Parameter(Mandatory)] [string] $VolumeLabel
)
if (Test-Path $IsoPath) { Remove-Item $IsoPath -Force }
$fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage
$result = $null
$stream = $null
try {
$fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF
$fsi.VolumeName = $VolumeLabel
$fsi.ChooseImageDefaultsForMediaType(13) # IMAPI_MEDIA_TYPE_DISK
$fsi.Root.AddTree($SourceFolder, $false)
$result = $fsi.CreateResultImage()
$stream = $result.ImageStream
[IsoStreamWriter2]::Save($stream, $IsoPath)
} finally {
# Release in reverse order. ImageStream holds file handles to source
# files added via AddTree — must be released before the source folder
# can be deleted.
if ($stream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($stream) | Out-Null }
if ($result) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($result) | Out-Null }
[System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($fsi) | Out-Null
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
}
}
function New-WinIsoNoPrompt {
<#
Rebuilds a Windows install ISO replacing the EFI boot image with
efisys_noprompt.bin (extracted from the source ISO itself).
Result: UEFI boot proceeds straight into Windows Setup, no
"Press any key to boot from CD" prompt.
#>
param(
[Parameter(Mandatory)] [string] $SourceIsoPath,
[Parameter(Mandatory)] [string] $OutputIsoPath
)
if (Test-Path $OutputIsoPath) { Remove-Item $OutputIsoPath -Force }
$mount = Mount-DiskImage -ImagePath $SourceIsoPath -PassThru -StorageType ISO -Access ReadOnly
try {
$vol = $mount | Get-Volume
$letter = "$($vol.DriveLetter):\"
$bootImg = Join-Path $letter 'efi\microsoft\boot\efisys_noprompt.bin'
if (-not (Test-Path $bootImg)) {
throw "efisys_noprompt.bin not present in source ISO at $bootImg"
}
$label = $vol.FileSystemLabel
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'WINSERVER' }
if ($label.Length -gt 32) { $label = $label.Substring(0,32) }
$fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage
try {
$fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF
$fsi.VolumeName = $label
$fsi.ChooseImageDefaultsForMediaType(13)
# 5+ GB ISO needs explicit large-volume defaults if available.
try { $fsi.UDFRevision = 0x102 } catch { Write-Verbose "UDF revision not supported on this IMAPI version." }
Write-Host " Adding ISO tree (this takes a few minutes for 5+ GB)..." -ForegroundColor Yellow
$fsi.Root.AddTree($letter, $false)
$bootStream = [IsoStreamWriter2]::OpenFileAsStream($bootImg)
$bootOpts = New-Object -ComObject IMAPI2FS.BootOptions
$bootOpts.AssignBootImage($bootStream)
$bootOpts.Emulation = 0 # IMAPI_BOOT_EMULATION_TYPE_NONE
$bootOpts.PlatformId = 0xEF # EFI
$bootOpts.Manufacturer = 'Microsoft'
# Single-element COM array for BootImageOptionsArray
$arr = New-Object 'object[]' 1
$arr[0] = $bootOpts
try { $fsi.BootImageOptionsArray = $arr }
catch { $fsi.BootImageOptions = $bootOpts } # fallback older IMAPI2FS
Write-Host " Building output ISO..." -ForegroundColor Yellow
$result = $fsi.CreateResultImage()
$stream = $result.ImageStream
[IsoStreamWriter2]::Save($stream, $OutputIsoPath)
} finally {
if ($stream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($stream) | Out-Null }
if ($result) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($result) | Out-Null }
if ($bootStream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($bootStream) | Out-Null }
if ($bootOpts) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($bootOpts) | Out-Null }
[System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($fsi) | Out-Null
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
}
} finally {
Dismount-DiskImage -ImagePath $SourceIsoPath -ErrorAction SilentlyContinue | Out-Null
}
}
function Invoke-Vmrun {
param([Parameter(Mandatory)] [string[]] $Arguments,
[switch] $IgnoreErrors,
[switch] $Async)
$vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe'
if ($Async) {
# vmrun start can block indefinitely on some Workstation versions; fire async.
Start-Process -FilePath $vmrun -ArgumentList $Arguments -NoNewWindow
return
}
$stdout = & $vmrun @Arguments 2>&1
$code = $LASTEXITCODE
if ($code -ne 0 -and -not $IgnoreErrors) {
throw "vmrun $($Arguments -join ' ') failed (exit $code): $stdout"
}
return $stdout
}
function Set-VmxKey {
param(
[Parameter(Mandatory)] [string] $VmxPath,
[Parameter(Mandatory)] [string] $Key,
[Parameter(Mandatory)] [string] $Value
)
$lines = Get-Content -LiteralPath $VmxPath
$pattern = "^\s*$([regex]::Escape($Key))\s*="
$found = $false
$newLines = foreach ($line in $lines) {
if ($line -match $pattern) { $found = $true; "$Key = `"$Value`"" }
else { $line }
}
if (-not $found) { $newLines = @($newLines) + "$Key = `"$Value`"" }
Set-Content -LiteralPath $VmxPath -Value $newLines -Encoding ASCII
}
function Remove-VmxLines {
param([Parameter(Mandatory)] [string] $VmxPath,
[Parameter(Mandatory)] [string] $KeyPrefix)
$pat = "^\s*$([regex]::Escape($KeyPrefix))"
$lines = Get-Content -LiteralPath $VmxPath | Where-Object { $_ -notmatch $pat }
Set-Content -LiteralPath $VmxPath -Value $lines -Encoding ASCII
}
function Test-VmInList {
param([Parameter(Mandatory)] [string] $VmxPath)
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
$forward = $VmxPath -replace '\\', '/'
$back = $VmxPath -replace '/', '\'
return ($list -match [regex]::Escape($forward)) -or ($list -match [regex]::Escape($back))
}
function Invoke-VmrunBounded {
# Run vmrun synchronously but kill the vmrun process after $TimeoutSeconds.
# Useful for 'stop soft': vmrun blocks until guest shuts down, but the ACPI
# signal is sent within seconds — killing vmrun doesn't cancel the guest shutdown.
param([Parameter(Mandatory)] [string[]] $Arguments,
[int] $TimeoutSeconds = 30)
$exe = Join-Path $VMwareWorkstationDir 'vmrun.exe'
$proc = Start-Process -FilePath $exe -ArgumentList $Arguments -NoNewWindow -PassThru
$null = $proc.WaitForExit($TimeoutSeconds * 1000)
if (-not $proc.HasExited) { $proc.Kill() }
}
function Stop-Vm {
param([Parameter(Mandatory)] [string] $VmxPath,
[int] $SoftTimeoutMinutes = 5)
# Send soft-stop (ACPI); vmrun may block until guest finishes — kill vmrun after 30 s.
# Guest OS continues shutdown independently once the signal is received.
Invoke-VmrunBounded -Arguments @('-T','ws','stop',$VmxPath,'soft') -TimeoutSeconds 30
$deadline = (Get-Date).AddMinutes($SoftTimeoutMinutes)
do {
Start-Sleep -Seconds 5
} while ((Get-Date) -lt $deadline -and (Test-VmInList -VmxPath $VmxPath))
if (Test-VmInList -VmxPath $VmxPath) {
Write-Host ' soft stop timed out — hard stop'
Invoke-VmrunBounded -Arguments @('-T','ws','stop',$VmxPath,'hard') -TimeoutSeconds 15
Start-Sleep -Seconds 5
}
}
# ─────────────────────────────────────────────────────────────────────────────
# Step 1: Pre-flight validation
# ─────────────────────────────────────────────────────────────────────────────
# ── Derived paths — always computed regardless of -StartFromStep ─────────────
$vmrunPath = Join-Path $VMwareWorkstationDir 'vmrun.exe'
$vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe'
$scriptRoot = Split-Path -Parent $PSCommandPath
$xmlTemplate = Join-Path $scriptRoot 'autounattend.template.xml'
$vmDir = Split-Path -Parent $VMXPath
$vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk'
$vmdkPath = Join-Path $vmDir $vmdkName
$autounattendIso = Join-Path $vmDir 'autounattend.iso'
$stagingDir = Join-Path $vmDir '_autounattend_staging'
$guestOS = 'windows2019srvnext-64'
$guestIP = $null # set in Step 9; pre-init for strict-mode safety
if ([string]::IsNullOrWhiteSpace($WinISONoPromptPath)) {
$srcDir = Split-Path -Parent $WinISO
$srcBase = [IO.Path]::GetFileNameWithoutExtension($WinISO)
$WinISONoPromptPath = Join-Path $srcDir "$srcBase.patched.iso"
}
if ($StartFromStep -gt 1) {
Write-Host " [SKIP] Steps 1..$($StartFromStep - 1) — starting from Step $StartFromStep" -ForegroundColor Yellow
}
# ─────────────────────────────────────────────────────────────────────────────
# Step 1: pre-flight validation
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 1: pre-flight validation' -Step 1
if ($StartFromStep -le 1) {
Assert-Step 'PreFlight' "vmrun.exe found at $vmrunPath" { Test-Path $vmrunPath }
Assert-Step 'PreFlight' "vmware-vdiskmanager.exe found at $vdiskMgr" { Test-Path $vdiskMgr }
Assert-Step 'PreFlight' "Windows ISO exists: $WinISO" { Test-Path $WinISO }
Assert-Step 'PreFlight' "VMware Tools ISO exists: $ToolsISO" { Test-Path $ToolsISO }
Assert-Step 'PreFlight' "autounattend template exists: $xmlTemplate" { Test-Path $xmlTemplate }
Assert-Step 'PreFlight' "ComputerName valid (1-15 chars, no spaces)" {
$ComputerName -match '^[A-Za-z0-9-]{1,15}$'
}
if (-not (Test-Path $vmDir)) {
New-Item -ItemType Directory -Path $vmDir -Force | Out-Null
}
Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir }
if (Test-Path $VMXPath) {
throw "[PreFlight] $VMXPath already exists. Remove the existing VM folder before re-running."
}
} # end Step 1
# ─────────────────────────────────────────────────────────────────────────────
# Step 2: render autounattend.xml + post-install.ps1
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 2: render autounattend.xml + post-install.ps1' -Step 2
if ($StartFromStep -le 2) {
if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force }
New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
# autounattend.xml
$xmlText = Get-Content -LiteralPath $xmlTemplate -Raw
$xmlSubs = @{
'{{LOCALE_OS}}' = $LocaleOS
'{{LOCALE_USER}}' = $LocaleUser
'{{KEYBOARD}}' = $InputKeyboard
'{{IMAGE_INDEX}}' = "$ImageIndex"
'{{PRODUCT_KEY}}' = $ProductKey
'{{COMPUTER_NAME}}' = $ComputerName
'{{TIMEZONE}}' = $TimeZone
'{{ADMIN_USER}}' = $AdminUser
'{{ADMIN_PWD}}' = $AdminPassword
}
foreach ($k in $xmlSubs.Keys) { $xmlText = $xmlText.Replace($k, $xmlSubs[$k]) }
$xmlOut = Join-Path $stagingDir 'autounattend.xml'
Set-Content -LiteralPath $xmlOut -Value $xmlText -Encoding UTF8
Assert-Step 'Render' 'autounattend.xml written' { Test-Path $xmlOut }
Assert-Step 'Render' 'no placeholders left in xml' { -not (Select-String -Path $xmlOut -Pattern '\{\{[^}]+\}\}' -Quiet) }
# post-install.ps1 — drops into autounattend ISO root, runs at FirstLogonCommands.
# Hardening + WinRM + Tools install + flag file + reboot. All paths are absolute.
$postInstall = @"
# Auto-generated by Deploy-WinBuild2022.ps1 runs at first logon as Administrator.
`$ErrorActionPreference = 'Continue'
`$log = 'C:\Windows\Temp\post-install.log'
function L(`$m){ "`$(Get-Date -f s) `$m" | Tee-Object -FilePath `$log -Append }
L 'post-install start'
# KICK OFF VMware Tools install (async wrapper detaches MSI child)
# Started first so the MSI runs in the background while hardening tweaks
# below execute. Settled state verified at the very end before reboot.
`$selfDir = Split-Path -Parent `$MyInvocation.MyCommand.Path
`$toolsExe = `$null
foreach (`$name in 'setup64.exe','setup.exe') {
`$cand = Join-Path `$selfDir "Tools\`$name"
if (Test-Path `$cand) { `$toolsExe = `$cand; break }
}
if (-not `$toolsExe) {
L 'Bundled Tools not found, scanning CD drives...'
foreach (`$drv in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=5')) {
foreach (`$name in 'setup64.exe','setup.exe') {
`$cand = Join-Path (`$drv.DeviceID + '\') `$name
if (Test-Path `$cand) { `$toolsExe = `$cand; break }
}
if (`$toolsExe) { break }
}
}
L "Tools setup resolved to: `$toolsExe"
`$localRoot = 'C:\Windows\Temp\VMwareTools'
`$msiLog = 'C:\Windows\Temp\vmtools-msi.log'
`$exitCode = -1
if (`$toolsExe -and (Test-Path `$toolsExe)) {
# Copy payload to local writable disk wrapper extract from CD fails at
# FirstLogon phase due to user profile %TEMP% ACL bootstrap timing.
`$srcRoot = Split-Path -Parent `$toolsExe
if (Test-Path `$localRoot) { Remove-Item `$localRoot -Recurse -Force }
L "Copying Tools payload `$srcRoot -> `$localRoot"
Copy-Item -Path `$srcRoot -Destination `$localRoot -Recurse -Force
# PS argument parser strips inner quotes from /v"..."; --% stop-parsing
# token passes the tail literally to the EXE.
L "Tools cmd: .\setup.exe /s /v`"/qn REBOOT=R /l*v `$msiLog`""
Push-Location `$localRoot
try {
& .\setup.exe --% /s /v"/qn REBOOT=R /l*v C:\Windows\Temp\vmtools-msi.log"
`$exitCode = `$LASTEXITCODE
} finally { Pop-Location }
L "VMware Tools wrapper exit code: `$exitCode (MSI continues async)"
} else {
L 'ERROR: VMware Tools setup not found (bundled or on any CD-ROM)'
}
# UAC off (admin script automation)
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v EnableLUA /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f | Out-Null
L 'UAC disabled'
# Defender disabled (lab only) Install-CIToolchain-WinBuild2022 skips Defender step
# RTP off + tamper-protect GPO. With Defender fully off, exclusion paths
# become moot (no scanner to exclude from), so Install-CIToolchain-WinBuild2022 has been
# stripped of its Defender step.
try { Set-MpPreference -DisableRealtimeMonitoring `$true -ErrorAction Stop; L 'Defender RTP off' }
catch { L "Defender RTP off failed: `$(`$_.Exception.Message)" }
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows Defender' /v DisableAntiSpyware /t REG_DWORD /d 1 /f | Out-Null
# Telemetry / DiagTrack off
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection' /v AllowTelemetry /t REG_DWORD /d 0 /f | Out-Null
sc.exe config DiagTrack start= disabled | Out-Null
sc.exe stop DiagTrack | Out-Null
L 'Telemetry disabled'
# Windows Update GPO locks services left Manual (§7.2 Strategy B)
# Set both WU services to Manual so they can be invoked on-demand by Setup
# Step 6 (WU COM API via SYSTEM task) but won't auto-start between Deploy and Setup.
# UsoSvc defaults to Automatic on Windows Server 2022; must be explicit here.
# GPO keys below also block background triggers. Step 6b permanently disables both.
Set-Service -Name wuauserv -StartupType Manual -ErrorAction SilentlyContinue
Set-Service -Name UsoSvc -StartupType Manual -ErrorAction SilentlyContinue
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' /v DisableWindowsUpdateAccess /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null
L 'Windows Update GPO locks set + wuauserv/UsoSvc Manual (Setup Step 6b permanently disables)'
# Firewall: all profiles disabled + allow RDP + ICMP + WinRM HTTPS
# Disabling all profiles removes any block on subsequent WinRM config and WU
# downloads. This VM lives on VMnet8 NAT behind the VMware NAT gateway no
# inbound exposure from outside the host. Install-CIToolchain-WinBuild2022 Step 1 validates.
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
L 'Windows Firewall disabled on all profiles'
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server' /v fDenyTSConnections /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' /v UserAuthentication /t REG_DWORD /d 0 /f | Out-Null
Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName 'ICMPv4-In' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -ErrorAction SilentlyContinue | Out-Null
L 'RDP + ICMP allowed'
# WinRM HTTPS-only/5986, Basic auth over HTTPS, TrustedHosts=* (lab only)
# AllowUnencrypted intentionally left false all WinRM traffic uses HTTPS/5986.
Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null
winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null
try {
`$cert = New-SelfSignedCertificate -DnsName `$env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint `$cert.Thumbprint -Force | Out-Null
New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any -RemoteAddress '192.168.79.0/24' | Out-Null
L 'WinRM HTTPS listener configured (port 5986, restricted to 192.168.79.0/24)'
} catch { L "WinRM HTTPS failed: `$(`$_.Exception.Message)" }
winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null
Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>`$null
Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>`$null
Set-Service -Name WinRM -StartupType Automatic 3>`$null
L 'WinRM shell limits: MaxMemory=2048MB IdleTimeout=2h MaxProcesses=25; StartType=Automatic'
# Server Manager / WAC popup / first-logon UX
# Server Manager auto-start off machine-wide + current Administrator + Default
# profile hive (so future cloned/sysprep'd users inherit).
reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKCU\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
reg load HKU\DefaultUser 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null
reg add 'HKU\DefaultUser\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
reg unload HKU\DefaultUser 2>&1 | Out-Null
# Windows Admin Center prompt at SM launch off
reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotPopWACConsoleAtSMLaunch /t REG_DWORD /d 1 /f | Out-Null
# DisableCAD no Ctrl+Alt+Del at logon (CI VMs never need it)
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null
# OOBE privacy experience suppressed
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null
L 'Server Manager + WAC + CAD + OOBE prompts disabled'
# Supplemental UX hardening validated by Install-CIToolchain-WinBuild2022 Step 5c:
# Lock screen: suppress at CI console sessions (no interactive user ever logs in)
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization' /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null
# Server Manager GPO policy key (authoritative path on WS2022; HKLM key above is not always honoured)
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' /v DoNotOpenAtLogon /t REG_DWORD /d 1 /f | Out-Null
# Server Manager scheduled task the actual launcher on WS2022; registry alone insufficient
schtasks /Change /TN '\Microsoft\Windows\Server Manager\ServerManager' /Disable 2>&1 | Out-Null
# DisableCAD in Winlogon (belt-and-suspenders with Policies\System key set above)
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null
# Autologin Administrator auto-signs in after every boot (CI template convenience).
# Password stored plaintext in registry; acceptable for isolated VMnet8 NAT lab VM.
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v AutoAdminLogon /t REG_SZ /d 1 /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultUserName /t REG_SZ /d Administrator /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultPassword /t REG_SZ /d "$AdminPassword" /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultDomainName /t REG_SZ /d '.' /f | Out-Null
# OOBE non-policy key (supplements the Policies\Windows\OOBE key set above)
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null
L 'Lock screen, SM GPO policy + task, CAD Winlogon, autologin Administrator, OOBE non-policy key set'
# IE Enhanced Security Configuration off (Admin)
# Long-known CI papercut: ESC blocks every URL in admin sessions.
reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null
Stop-Process -Name iexplore -ErrorAction SilentlyContinue
L 'IE ESC disabled'
# Remove drive letter from EFI System Partition
# Windows setup sometimes assigns 'S:' to the ESP making it visible in Explorer.
Get-Partition | Where-Object { `$_.DriveLetter -eq 'S' } | ForEach-Object {
`$_ | Remove-PartitionAccessPath -AccessPath 'S:\' -ErrorAction SilentlyContinue
}
L 'EFI partition drive letter removed (if any)'
# Hibernate off (frees ~RAM-size on C:, no need on VMs)
powercfg /h off 2>&1 | Out-Null
# Power plan High Performance never sleep, never throttle CPU
powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c 2>&1 | Out-Null
powercfg /change monitor-timeout-ac 0 | Out-Null
powercfg /change disk-timeout-ac 0 | Out-Null
powercfg /change standby-timeout-ac 0 | Out-Null
powercfg /change hibernate-timeout-ac 0 | Out-Null
L 'Hibernate off, power plan High Performance'
# Unneeded services off (CI build VM, not a print/search/file server)
foreach (`$svc in 'WSearch','Spooler','SysMain','WerSvc','RemoteRegistry','Fax','MapsBroker','RetailDemo','XblAuthManager','XblGameSave','XboxNetApiSvc','XboxGipSvc') {
`$s = Get-Service -Name `$svc -ErrorAction SilentlyContinue
if (`$s) {
sc.exe config `$svc start= disabled | Out-Null
sc.exe stop `$svc | Out-Null
L " service disabled: `$svc"
}
}
# Windows Customer Experience tasks off
foreach (`$task in @(
'\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser',
'\Microsoft\Windows\Application Experience\ProgramDataUpdater',
'\Microsoft\Windows\Autochk\Proxy',
'\Microsoft\Windows\Customer Experience Improvement Program\Consolidator',
'\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip',
'\Microsoft\Windows\Feedback\Siuf\DmClient',
'\Microsoft\Windows\Feedback\Siuf\DmClientOnScenarioDownload'
)) {
schtasks /Change /TN `$task /Disable 2>&1 | Out-Null
}
L 'CEIP / feedback scheduled tasks disabled'
# Azure Arc auto-start / tray icon off
# Server 2022 ships with Azure Arc integration enabled; suppress for CI VMs.
foreach (`$arcTask in @(
'\Microsoft\Azure Arc\Onboarding\EnableAzureArcSetup',
'\Microsoft\Azure Arc\Onboarding\ArcScan',
'\Microsoft\AzureArcSeamlessOnboarding\ArcSetup'
)) {
schtasks /Change /TN `$arcTask /Disable 2>&1 | Out-Null
}
foreach (`$arcSvc in 'himds','ArcProxyAgent') {
`$s = Get-Service -Name `$arcSvc -ErrorAction SilentlyContinue
if (`$s) {
sc.exe config `$arcSvc start= disabled | Out-Null
sc.exe stop `$arcSvc 2>&1 | Out-Null
}
}
reg add 'HKLM\SOFTWARE\Microsoft\AzureArc' /v OnboardingState /t REG_DWORD /d 4 /f 2>&1 | Out-Null
reg delete 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' /v AzureArcSetup /f 2>&1 | Out-Null
L 'Azure Arc auto-start disabled'
# Explorer UX: show file extensions + open to This PC
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v HideFileExt /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v LaunchTo /t REG_DWORD /d 1 /f | Out-Null
L 'Explorer UX tweaks applied (HKCU)'
# Taskbar / terminal UX tweaks
# SearchboxTaskbarMode=0 hide search bar
# ShowTaskViewButton=0 hide Task View button
# DelegationConsole/Terminal Windows Terminal as default terminal
# LayoutModification.xml pre-pin Windows Terminal for new user profiles
# HKCU (SYSTEM context at FirstLogon)
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKCU\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
reg add 'HKCU\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
# Default hive Administrator + any future user inherits on first interactive logon
reg load HKU\DefaultUser2 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null
reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
reg unload HKU\DefaultUser2 2>&1 | Out-Null
L 'Taskbar: search hidden, task view hidden, WT default terminal'
# Wait for VMware Tools install to settle (kicked off at top)
# Wrapper returned immediately while msiexec runs async. Poll until:
# (a) no setup/setup64/vminst wrapper process (excludes system msiexec /V)
# (b) vmtoolsd.exe on disk
# (c) VMTools service Running (not just registered install finalized)
# Hard timeout 15 min.
if (`$toolsExe) {
`$installDeadline = (Get-Date).AddMinutes(15)
while ((Get-Date) -lt `$installDeadline) {
Start-Sleep -Seconds 5
`$busy = Get-Process -ErrorAction SilentlyContinue |
Where-Object { `$_.ProcessName -in 'setup','setup64','VMware-Tools','VMware-Tools-Installer','vminst' }
`$vmtoolsd = Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe'
`$svc = Get-Service -Name VMTools -ErrorAction SilentlyContinue
`$svcRunning = (`$svc -and `$svc.Status -eq 'Running')
L " install poll: busy=`$(`$busy.Count) vmtoolsd=`$vmtoolsd svc=`$(if (`$svc) { `$svc.Status } else { 'absent' })"
if (-not `$busy -and `$vmtoolsd -and `$svcRunning) { L 'Tools install settled.'; break }
}
if (Test-Path `$msiLog) {
L "---- MSI log tail (last 30 lines) ----"
Get-Content `$msiLog -Tail 30 | ForEach-Object { L `$_ }
L '---- end MSI log ----'
} else {
L "MSI log NOT created at `$msiLog (wrapper failed before MSI launched)"
}
if (Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe') {
L 'vmtoolsd.exe present -> Tools installed OK'
} else {
L "WARNING: vmtoolsd.exe NOT present -> install failed (wrapper exit `$exitCode)"
}
}
# Drop completion flag (host polls for this via vmrun)
New-Item -ItemType File -Path 'C:\Windows\Temp\install_complete.flag' -Force | Out-Null
L 'install_complete.flag dropped'
# Shut down (not restart) so host Step 8 can swap NIC and snapshot
# Delay 60s: host polls every 30s, needs one cycle after flag appears.
# Step 8 sends vmrun stop soft on top of this Windows handles both.
L 'shutting down in 60s'
shutdown /s /t 60 /f /c "post-install complete"
"@
$psOut = Join-Path $stagingDir 'post-install.ps1'
Set-Content -LiteralPath $psOut -Value $postInstall -Encoding UTF8
Assert-Step 'Render' 'post-install.ps1 written' { Test-Path $psOut }
# Bundle VMware Tools contents under autounattend\Tools\ so post-install.ps1
# always finds setup64.exe on the same CD it boots from. Avoids the
# "third SATA CD-ROM disconnected at first logon" failure mode.
Write-Host ' Extracting VMware Tools ISO into autounattend staging...' -ForegroundColor Yellow
$toolsStage = Join-Path $stagingDir 'Tools'
New-Item -ItemType Directory -Path $toolsStage -Force | Out-Null
$toolsMount = Mount-DiskImage -ImagePath $ToolsISO -PassThru -Access ReadOnly
try {
$toolsLetter = "$(($toolsMount | Get-Volume).DriveLetter):\"
Copy-Item -Path (Join-Path $toolsLetter '*') -Destination $toolsStage -Recurse -Force
} finally {
Dismount-DiskImage -ImagePath $ToolsISO -ErrorAction SilentlyContinue | Out-Null
}
Assert-Step 'Render' 'Tools setup bundled in staging\Tools' {
(Test-Path (Join-Path $toolsStage 'setup.exe')) -or
(Test-Path (Join-Path $toolsStage 'setup64.exe'))
}
} # end Step 2
# ─────────────────────────────────────────────────────────────────────────────
# Step 3: build autounattend ISO
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 3: build autounattend ISO via IMAPI2FS' -Step 3
if ($StartFromStep -le 3) {
New-IsoFromFolder -SourceFolder $stagingDir -IsoPath $autounattendIso -VolumeLabel 'AUTOUNATTEND'
Assert-Step 'IsoBuild' "autounattend.iso created at $autounattendIso" { Test-Path $autounattendIso }
Assert-Step 'IsoBuild' 'autounattend.iso non-empty' { (Get-Item $autounattendIso).Length -gt 4096 }
# Cleanup staging — only ISO matters from here on. IMAPI2FS may hold a
# transient lock on bundled binaries; force GC then retry, then ignore.
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
Start-Sleep -Seconds 2
Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $stagingDir) {
Write-Host " [warn] staging dir not fully cleaned, leaving in place: $stagingDir" -ForegroundColor Yellow
}
} # end Step 3
# ─────────────────────────────────────────────────────────────────────────────
# Step 4: create VMDK + no-prompt Windows ISO
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Step 4: create $DiskSizeGB GB VMDK (single-file growable)" -Step 4
if ($StartFromStep -le 4) {
# -t 0 = single growable file (thin); -a lsilogic affects descriptor only,
# the disk attaches to the NVMe controller defined in the VMX.
$vdiskArgs = @('-c', '-s', "${DiskSizeGB}GB", '-a', 'lsilogic', '-t', '0', $vmdkPath)
& $vdiskMgr @vdiskArgs
if ($LASTEXITCODE -ne 0) { throw "vmware-vdiskmanager failed (exit $LASTEXITCODE)" }
Assert-Step 'Vmdk' "VMDK file present: $vmdkPath" { Test-Path $vmdkPath }
# ─────────────────────────────────────────────────────────────────────────────
# Step 4b: rebuild Windows ISO with EFI no-prompt boot image
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")' -Step 4
$srcMtime = (Get-Item $WinISO).LastWriteTime
$rebuild = $true
if (Test-Path $WinISONoPromptPath) {
if ((Get-Item $WinISONoPromptPath).LastWriteTime -ge $srcMtime) {
Write-Host " Cached no-prompt ISO is current, reusing: $WinISONoPromptPath" -ForegroundColor Green
$rebuild = $false
}
}
if ($rebuild) {
Write-Host " Building no-prompt ISO from $WinISO" -ForegroundColor Yellow
Write-Host " Output: $WinISONoPromptPath" -ForegroundColor Yellow
New-WinIsoNoPrompt -SourceIsoPath $WinISO -OutputIsoPath $WinISONoPromptPath
}
Assert-Step 'NoPromptIso' "no-prompt ISO present: $WinISONoPromptPath" { Test-Path $WinISONoPromptPath }
Assert-Step 'NoPromptIso' 'no-prompt ISO non-empty (>500 MB)' { (Get-Item $WinISONoPromptPath).Length -gt 500MB }
} # end Step 4
# ─────────────────────────────────────────────────────────────────────────────
# Step 5: generate VMX
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 5: generate VMX' -Step 5
if ($StartFromStep -le 5) {
# guestOS:
# "windows2019srvnext-64" is the generic forward-compat ID for new Windows
# Server releases on Workstation. Workstation 17.6+ accepts it for Server 2022.
# If your Workstation version supports the explicit ID, change it here.
# ($guestOS is set unconditionally in the derived-paths block above.)
# CD-ROM ordering (sata):
# sata0:0 = Windows install ISO (bootable) ← EFI picks this on first boot
# sata0:1 = autounattend ISO (non-bootable, scanned by Setup; Tools bundled inside)
# Both disconnected in Step 8 after install — snapshot/clone boots clean with no ISOs.
$vmxLines = @(
'.encoding = "windows-1252"',
'config.version = "8"',
'virtualHW.version = "21"',
"displayName = `"$VMName`"",
"guestOS = `"$guestOS`"",
'firmware = "efi"',
'uefi.secureBoot.enabled = "FALSE"',
"memSize = `"$MemoryMB`"",
"numvcpus = `"$VCPUCount`"",
"cpuid.coresPerSocket = `"$CoresPerSocket`"",
'tools.syncTime = "TRUE"',
'tools.upgrade.policy = "manual"',
'powerType.powerOff = "soft"',
'powerType.powerOn = "soft"',
'powerType.suspend = "soft"',
'powerType.reset = "soft"',
'uuid.action = "create"',
'',
'# PCIe root ports — required for explicit PCI slot assignments below.',
'# Each pcieRootPort exposes 8 slots → enough headroom for NVMe + SATA + NIC.',
'pciBridge0.present = "TRUE"',
'pciBridge4.present = "TRUE"',
'pciBridge4.virtualDev = "pcieRootPort"',
'pciBridge4.functions = "8"',
'pciBridge5.present = "TRUE"',
'pciBridge5.virtualDev = "pcieRootPort"',
'pciBridge5.functions = "8"',
'pciBridge6.present = "TRUE"',
'pciBridge6.virtualDev = "pcieRootPort"',
'pciBridge6.functions = "8"',
'pciBridge7.present = "TRUE"',
'pciBridge7.virtualDev = "pcieRootPort"',
'pciBridge7.functions = "8"',
'',
'# NVMe controller + 80 GB system disk',
'nvme0.present = "TRUE"',
'nvme0.pciSlotNumber = "224"',
'nvme0:0.present = "TRUE"',
"nvme0:0.fileName = `"$vmdkName`"",
'nvme0:0.deviceType = "disk"',
'',
'# Two SATA CD-ROMs: install ISO + autounattend ISO (Tools bundled in autounattend)',
'sata0.present = "TRUE"',
'sata0.pciSlotNumber = "32"',
'sata0:0.present = "TRUE"',
'sata0:0.deviceType = "cdrom-image"',
"sata0:0.fileName = `"$WinISONoPromptPath`"",
'sata0:0.startConnected = "TRUE"',
'sata0:1.present = "TRUE"',
'sata0:1.deviceType = "cdrom-image"',
"sata0:1.fileName = `"$autounattendIso`"",
'sata0:1.startConnected = "TRUE"',
'',
'# NIC: e1000e for install (broad compat); switched to vmxnet3 after Tools',
'ethernet0.present = "TRUE"',
'ethernet0.virtualDev = "e1000e"',
'ethernet0.connectionType = "nat"',
'ethernet0.addressType = "generated"',
'ethernet0.startConnected = "TRUE"',
'ethernet0.pciSlotNumber = "192"',
'',
'# USB / video',
'usb.present = "TRUE"',
'usb.pciSlotNumber = "34"',
'ehci.present = "TRUE"',
'ehci.pciSlotNumber = "35"',
'usb_xhci.present = "TRUE"',
'usb_xhci.pciSlotNumber = "33"',
'svga.present = "TRUE"',
'svga.graphicsMemoryKB = "262144"',
'',
'# No floppy / no sound (CI VM)',
'floppy0.present = "FALSE"',
'sound.present = "FALSE"',
'',
'# Misc',
'vmci0.present = "TRUE"',
'vmci0.pciSlotNumber = "36"',
'hpet0.present = "TRUE"',
'mks.enable3d = "FALSE"',
'snapshot.action = "keep"'
)
Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII
Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath }
} # end Step 5
# ─────────────────────────────────────────────────────────────────────────────
# Step 6: power on VM
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Step 6: power on VM (vmrun start $vmrunUiMode)" -Step 6
if ($StartFromStep -le 6) {
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
Start-Sleep -Seconds 10
Assert-Step 'PowerOn' 'VM appears in vmrun list' { Test-VmInList -VmxPath $VMXPath }
} # end Step 6
# ─────────────────────────────────────────────────────────────────────────────
# Step 7: wait for install_complete.flag inside guest
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Step 7: wait for guest install_complete.flag (max ${GuestPollMaxMinutes} min)" -Step 7
if ($StartFromStep -le 7) {
$deadline = (Get-Date).AddMinutes($GuestPollMaxMinutes)
$flagPath = 'C:\Windows\Temp\install_complete.flag'
$flagSeen = $false
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds $GuestPollSeconds
$toolsState = Invoke-Vmrun -Arguments @('-T','ws','checkToolsState',$VMXPath) -IgnoreErrors
Write-Host (" [{0:HH:mm:ss}] toolsState={1}" -f (Get-Date), ($toolsState -join ' '))
if ($toolsState -notmatch 'running') { continue }
# Tools running → can probe filesystem with guest creds.
$probe = & $vmrunPath -T ws -gu $AdminUser -gp $AdminPassword `
fileExistsInGuest $VMXPath $flagPath 2>&1
Write-Host (" fileExistsInGuest -> {0}" -f ($probe -join ' '))
if ($probe -match 'The file exists') { $flagSeen = $true; break }
}
Assert-Step 'GuestReady' "install_complete.flag observed in guest" { $flagSeen }
} # end Step 7
# ─────────────────────────────────────────────────────────────────────────────
# Step 8: stop VM, disconnect ISOs, swap NIC e1000e → vmxnet3
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 8: stop VM, disconnect ISOs, swap NIC to vmxnet3' -Step 8
if ($StartFromStep -le 8) {
Stop-Vm -VmxPath $VMXPath
Assert-Step 'Stop' 'VM no longer in vmrun list' { -not (Test-VmInList -VmxPath $VMXPath) }
# Remove CD-ROM controller and USB from VMX — not needed post-install.
foreach ($prefix in 'sata0','usb','ehci','usb_xhci') {
Remove-VmxLines -VmxPath $VMXPath -KeyPrefix $prefix
}
Assert-Step 'CleanVmx' 'CD-ROM and USB entries removed from VMX' {
$vmx = Get-Content -LiteralPath $VMXPath -Raw
($vmx -notmatch '(?m)^\s*sata0') -and ($vmx -notmatch '(?m)^\s*usb')
}
Set-VmxKey -VmxPath $VMXPath -Key 'ethernet0.virtualDev' -Value 'vmxnet3'
Assert-Step 'NicSwap' 'ethernet0.virtualDev = vmxnet3' {
(Get-Content -LiteralPath $VMXPath) -match '^\s*ethernet0\.virtualDev\s*=\s*"vmxnet3"\s*$'
}
} # end Step 8
# ─────────────────────────────────────────────────────────────────────────────
# Step 9: power on, verify vmxnet3 + DHCP IP
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 9: power on with vmxnet3, verify DHCP IP' -Step 9
if ($StartFromStep -le 9) {
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
Start-Sleep -Seconds 10
$ipDeadline = (Get-Date).AddMinutes(10)
$guestIP = $null
do {
Start-Sleep -Seconds 10
$guestIP = Invoke-Vmrun -Arguments @('-T','ws','getGuestIPAddress',$VMXPath,'-wait') -IgnoreErrors
Write-Host " guestIP probe -> $guestIP"
} while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+'))
Assert-Step 'IPCheck' 'Guest IP obtained on vmxnet3' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' }
} # end Step 9
# ─────────────────────────────────────────────────────────────────────────────
# Step 10: shutdown → snapshot (cold) → power on
# ─────────────────────────────────────────────────────────────────────────────
Write-Step "Step 10: graceful shutdown, snapshot '$SnapshotName' (cold), power on" -Step 10
if ($StartFromStep -le 10) {
Stop-Vm -VmxPath $VMXPath
Assert-Step 'Snapshot' 'VM powered off before snapshot' { -not (Test-VmInList -VmxPath $VMXPath) }
Invoke-Vmrun -Arguments @('-T','ws','snapshot',$VMXPath,$SnapshotName) | Out-Null
$snaps = Invoke-Vmrun -Arguments @('-T','ws','listSnapshots',$VMXPath)
Assert-Step 'Snapshot' "Snapshot '$SnapshotName' present" { $snaps -match [regex]::Escape($SnapshotName) }
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
Start-Sleep -Seconds 10
$ipDeadline = (Get-Date).AddMinutes(5)
do {
Start-Sleep -Seconds 10
$guestIP = Invoke-Vmrun -Arguments @('-T','ws','getGuestIPAddress',$VMXPath,'-wait') -IgnoreErrors
} while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+'))
Assert-Step 'Snapshot' 'VM back online after snapshot' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' }
} # end Step 10
# ─────────────────────────────────────────────────────────────────────────────
# Final summary
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Done'
Write-Host ""
Write-Host " VMX : $VMXPath"
Write-Host " VMDK : $vmdkPath"
Write-Host " Hostname : $ComputerName"
Write-Host " Guest IP : $guestIP"
Write-Host " Admin user : $AdminUser"
Write-Host " Snapshot : $SnapshotName"
Write-Host ""
Write-Host " RDP : mstsc /v:$guestIP"
Write-Host " WinRM HTTPS : 5986 (self-signed, AllowUnencrypted=false)"
Write-Host ""
Write-Host " Next steps (from template\ dir):"
Write-Host " .\Validate-DeployState.ps1 -VMIPAddress $guestIP"
Write-Host " .\Prepare-WinBuild2022.ps1 -VMIPAddress $guestIP [-SkipWindowsUpdate]"
Write-Host ""
Write-Host " VM is powered on with vmxnet3 + DHCP. Snapshot '$SnapshotName' taken." -ForegroundColor Green