#Requires -RunAsAdministrator #Requires -Version 5.1 <# .SYNOPSIS Deploys an unattended Windows Server 2025 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-WinBuild2025 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-WinBuild2025 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 2025 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 2025 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 2025 Standard Desktop Experience on the VOL ISO). Verify with: dism /Get-WimInfo /WimFile:\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_2025_updated_april_2026_x64_dvd_225d826d.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\WinBuild2025\WinBuild2025.vmx', [string] $VMName = 'WinBuild2025', # ── Windows install ── [string] $ProductKey = 'TVRH6-WHNXV-R9WG3-9XRFY-MY832', [string] $ComputerName = 'WINBUILD-2025', [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-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-WinBuild2025.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-WinBuild2025 skips Defender step ─ # RTP off + tamper-protect GPO. With Defender fully off, exclusion paths # become moot (no scanner to exclude from), so Install-CIToolchain-WinBuild2025 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 2025; 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-WinBuild2025 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-WinBuild2025 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 WS2025; 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 WS2025; 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 2025 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 2025. # 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-WinBuild2025.ps1 -VMIPAddress $guestIP [-SkipWindowsUpdate]" Write-Host "" Write-Host " VM is powered on with vmxnet3 + DHCP. Snapshot '$SnapshotName' taken." -ForegroundColor Green