7d6ae42fcf
Fix emerged during §7.4 e2e test run (2026-05-10): - Deploy: set UsoSvc to Manual explicitly (Server 2025 default = Automatic) - Setup cleanup: re-apply Set-Service Disabled on wuauserv/UsoSvc after DISM StartComponentCleanup (WaaSMedicSvc resets StartType during component store cleanup) - Deploy + Setup: add Administrator autologin (AutoAdminLogon, DefaultUserName, DefaultPassword, DefaultDomainName) in post-install.ps1; Assert-Step 5c validates it - Add Validate-DeployState.ps1: standalone host-side check of all Deploy-set state - Add Validate-SetupState.ps1: standalone host-side check of full post-Setup state - Mark §7.4, §7.1 and §7.2 e2e validation items as complete in TODO.md - Update docs: WINDOWS-TEMPLATE-SETUP.md (arch table, validation scripts section, autologin in confine Deploy/Setup); TEST-7.4-e2e.md checklist marked done Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1114 lines
57 KiB
PowerShell
1114 lines
57 KiB
PowerShell
#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 — Setup-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 HTTP 5985 + HTTPS 5986 (self-signed cert), Basic auth,
|
||
AllowUnencrypted, 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;
|
||
Setup-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:<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\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 = 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) — 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 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. Setup-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 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)" }
|
||
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 Setup-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 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
|