#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, three CD-ROMs: Win install + autounattend + VMware Tools) Step 6 — Power on the VM (vmrun start nogui) 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, swap NIC e1000e → vmxnet3 in the .vmx 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 - DiagTrack/Telemetry off - Windows Update services disabled - RDP enabled (no NLA, lab only) + firewall rule - WinRM enabled HTTP 5985 + HTTPS 5986 (self-signed cert), Basic auth, AllowUnencrypted, TrustedHosts=* - ICMPv4 inbound allowed - 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). .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\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.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 = 2, [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 ) $vmrunUiMode = if ($ShowGui) { 'gui' } else { 'nogui' } Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── function Write-Step { param([string]$Message) 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) $vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe' $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 } # ───────────────────────────────────────────────────────────────────────────── # Step 1: Pre-flight validation # ───────────────────────────────────────────────────────────────────────────── Write-Step 'Step 1: pre-flight validation' $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' 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}$' } $vmDir = Split-Path -Parent $VMXPath 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." } $vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk' $vmdkPath = Join-Path $vmDir $vmdkName $autounattendIso = Join-Path $vmDir 'autounattend.iso' $stagingDir = Join-Path $vmDir '_autounattend_staging' # ───────────────────────────────────────────────────────────────────────────── # Step 2: render autounattend.xml + post-install.ps1 # ───────────────────────────────────────────────────────────────────────────── Write-Step 'Step 2: render autounattend.xml + post-install.ps1' 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) — Setup-WinBuild2025 skips Defender step ─ # RTP off + tamper-protect GPO. With Defender fully off, exclusion paths # become moot (no scanner to exclude from), so Setup-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 services disabled ────────────────────────────────────── foreach (`$svc in 'wuauserv','UsoSvc','WaaSMedicSvc') { sc.exe config `$svc start= disabled | Out-Null sc.exe stop `$svc | Out-Null } reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null L 'Windows Update disabled' # ── Firewall: profiles stay default; allow RDP + ICMP + WinRM HTTPS ────── 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 HTTP + HTTPS, Basic+Unencrypted, TrustedHosts=* (lab only) ───── Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null winrm set winrm/config/service '@{AllowUnencrypted="true"}' | 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 | Out-Null L 'WinRM HTTPS listener configured' } catch { L "WinRM HTTPS failed: `$(`$_.Exception.Message)" } # ── 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' # ── 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' # ── 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' # ── 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)' # ── 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' # ── Reboot to apply Tools/UAC/services state cleanly ────────────────────── L 'rebooting in 15s' shutdown /r /t 15 /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')) } # ───────────────────────────────────────────────────────────────────────────── # Step 3: build autounattend ISO # ───────────────────────────────────────────────────────────────────────────── Write-Step 'Step 3: build autounattend ISO via IMAPI2FS' 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 } # ───────────────────────────────────────────────────────────────────────────── # Step 4: create VMDK # ───────────────────────────────────────────────────────────────────────────── Write-Step "Step 4: create $DiskSizeGB GB VMDK (single-file growable)" # -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")' if ([string]::IsNullOrWhiteSpace($WinISONoPromptPath)) { $srcDir = Split-Path -Parent $WinISO $srcBase = [IO.Path]::GetFileNameWithoutExtension($WinISO) $WinISONoPromptPath = Join-Path $srcDir "$srcBase.patched.iso" } $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 } # ───────────────────────────────────────────────────────────────────────────── # Step 5: generate VMX # ───────────────────────────────────────────────────────────────────────────── Write-Step 'Step 5: generate VMX' # 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 = 'windows2019srvnext-64' # 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) # sata0:2 = VMware Tools ISO (Tools installer source) $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"', '', '# Three SATA CD-ROMs: install / autounattend / Tools', '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"', 'sata0:2.present = "TRUE"', 'sata0:2.deviceType = "cdrom-image"', "sata0:2.fileName = `"$ToolsISO`"", 'sata0:2.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 } # ───────────────────────────────────────────────────────────────────────────── # Step 6: power on VM # ───────────────────────────────────────────────────────────────────────────── Write-Step 'Step 6: power on VM (vmrun start nogui)' Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null Start-Sleep -Seconds 5 $running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors Assert-Step 'PowerOn' 'VM appears in vmrun list' { $running -match [regex]::Escape($VMXPath) } # ───────────────────────────────────────────────────────────────────────────── # Step 7: wait for install_complete.flag inside guest # ───────────────────────────────────────────────────────────────────────────── Write-Step "Step 7: wait for guest install_complete.flag (max ${GuestPollMaxMinutes} min)" $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 } # ───────────────────────────────────────────────────────────────────────────── # Step 8: graceful soft-stop, swap NIC e1000e → vmxnet3 # ───────────────────────────────────────────────────────────────────────────── Write-Step 'Step 8: stop VM softly, swap NIC to vmxnet3' Invoke-Vmrun -Arguments @('-T','ws','stop',$VMXPath,'soft') -IgnoreErrors | Out-Null # Wait for VM to drop out of running list (max 3 min). $stopDeadline = (Get-Date).AddMinutes(3) do { Start-Sleep -Seconds 5 $still = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors } while ((Get-Date) -lt $stopDeadline -and ($still -match [regex]::Escape($VMXPath))) Assert-Step 'Stop' 'VM no longer in vmrun list' { $list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors -not ($list -match [regex]::Escape($VMXPath)) } 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*$' } # ───────────────────────────────────────────────────────────────────────────── # Step 9: power on, verify vmxnet3 + DHCP IP # ───────────────────────────────────────────────────────────────────────────── Write-Step 'Step 9: power on with vmxnet3, verify DHCP IP' Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null $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+' } # ───────────────────────────────────────────────────────────────────────────── # Step 10: shutdown → snapshot (cold) → power on # ───────────────────────────────────────────────────────────────────────────── Write-Step "Step 10: graceful shutdown, snapshot '$SnapshotName' (cold), power on" Invoke-Vmrun -Arguments @('-T','ws','stop',$VMXPath,'soft') -IgnoreErrors | Out-Null $stopDeadline = (Get-Date).AddMinutes(3) do { Start-Sleep -Seconds 5 $running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors } while ((Get-Date) -lt $stopDeadline -and ($running -match [regex]::Escape($VMXPath))) Assert-Step 'Snapshot' 'VM powered off before snapshot' { $list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors -not ($list -match [regex]::Escape($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) | Out-Null $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+' } # ───────────────────────────────────────────────────────────────────────────── # 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 HTTP : 5985" Write-Host " WinRM HTTPS : 5986 (self-signed)" Write-Host "" Write-Host " VM is powered on with vmxnet3 + DHCP. Live snapshot taken." -ForegroundColor Green