feat(template): add Deploy-WinBuild2025.ps1 + autounattend; update TODO
template/Deploy-WinBuild2025.ps1:
New host-side script that drives the unattended Windows install phase:
creates the VM, injects autounattend.xml (disabling Defender/firewall/UAC
before Setup-WinBuild2025.ps1 runs), boots from ISO, waits for first-boot.
template/autounattend.template.xml:
WiM-based answer file template for Windows Server 2025 unattended install.
Sets DisableAntiSpyware GPO + RTP off so Defender is fully off at first
logon (prerequisite for Setup-WinBuild2025.ps1 Step-2 validation-only path).
TODO.md:
- Date + wording updated (2026-05-10)
- §1.1: file refs updated to post-refactor line numbers (Step 3 WinRM,
Deploy-WinBuild2025 Enable-PSRemoting, Invoke-RemoteBuild, Get-BuildArtifacts)
- §1.2: TrustedHosts audit status corrected (Setup-Host.ps1 never sets '*';
Prepare appends IP and restores in finally)
- §1.3: Python/dotnet/VS Build Tools line refs updated
- §1.5: PAT security constraints expanded with grep-on-log safety net rule
- §1.6: Defender/Firewall/UAC state updated to reflect Deploy vs Setup split
- §3.3 deploy reference: VMX path corrected to CI-WinBuild.vmx
- §3.3 doc ref: WINDOWS-TEMPLATE-SETUP updated to list Deploy step
This commit is contained in:
@@ -0,0 +1,938 @@
|
||||
#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:<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 = 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
|
||||
@@ -0,0 +1,195 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Windows Server 2025 unattended install template.
|
||||
Double-brace placeholders are substituted by Deploy-WinBuild2025.ps1.
|
||||
|
||||
Boot NIC is e1000e (broad compat). VMware Tools install brings
|
||||
vmxnet3 driver; the host script switches the VMX to vmxnet3
|
||||
after Tools install completes.
|
||||
|
||||
Disk layout (UEFI/GPT, 80 GB):
|
||||
Part 1: EFI 100 MB FAT32 S:
|
||||
Part 2: MSR 16 MB (no fmt)
|
||||
Part 3: Primary rest NTFS C:
|
||||
No Recovery partition (per user choice).
|
||||
|
||||
Image index 2 = "Windows Server 2025 SERVERSTANDARD" (Desktop Experience)
|
||||
on the VOL ISO. Verify with:
|
||||
dism /Get-WimInfo /WimFile:D:\sources\install.wim
|
||||
-->
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend"
|
||||
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
|
||||
<settings pass="windowsPE">
|
||||
<component name="Microsoft-Windows-International-Core-WinPE"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<SetupUILanguage>
|
||||
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||
</SetupUILanguage>
|
||||
<InputLocale>{{KEYBOARD}}</InputLocale>
|
||||
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
|
||||
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
|
||||
<UserLocale>{{LOCALE_USER}}</UserLocale>
|
||||
</component>
|
||||
|
||||
<component name="Microsoft-Windows-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<DiskConfiguration>
|
||||
<Disk wcm:action="add">
|
||||
<DiskID>0</DiskID>
|
||||
<WillWipeDisk>true</WillWipeDisk>
|
||||
<CreatePartitions>
|
||||
<CreatePartition wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Type>EFI</Type>
|
||||
<Size>100</Size>
|
||||
</CreatePartition>
|
||||
<CreatePartition wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<Type>MSR</Type>
|
||||
<Size>16</Size>
|
||||
</CreatePartition>
|
||||
<CreatePartition wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<Type>Primary</Type>
|
||||
<Extend>true</Extend>
|
||||
</CreatePartition>
|
||||
</CreatePartitions>
|
||||
<ModifyPartitions>
|
||||
<ModifyPartition wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<PartitionID>1</PartitionID>
|
||||
<Format>FAT32</Format>
|
||||
<Label>System</Label>
|
||||
<Letter>S</Letter>
|
||||
</ModifyPartition>
|
||||
<ModifyPartition wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<PartitionID>2</PartitionID>
|
||||
</ModifyPartition>
|
||||
<ModifyPartition wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<PartitionID>3</PartitionID>
|
||||
<Format>NTFS</Format>
|
||||
<Label>Windows</Label>
|
||||
<Letter>C</Letter>
|
||||
</ModifyPartition>
|
||||
</ModifyPartitions>
|
||||
</Disk>
|
||||
</DiskConfiguration>
|
||||
|
||||
<ImageInstall>
|
||||
<OSImage>
|
||||
<InstallFrom>
|
||||
<MetaData wcm:action="add">
|
||||
<Key>/IMAGE/INDEX</Key>
|
||||
<Value>{{IMAGE_INDEX}}</Value>
|
||||
</MetaData>
|
||||
</InstallFrom>
|
||||
<InstallTo>
|
||||
<DiskID>0</DiskID>
|
||||
<PartitionID>3</PartitionID>
|
||||
</InstallTo>
|
||||
</OSImage>
|
||||
</ImageInstall>
|
||||
|
||||
<UserData>
|
||||
<AcceptEula>true</AcceptEula>
|
||||
<FullName>CI</FullName>
|
||||
<Organization>CI Lab</Organization>
|
||||
<ProductKey>
|
||||
<Key>{{PRODUCT_KEY}}</Key>
|
||||
<WillShowUI>OnError</WillShowUI>
|
||||
</ProductKey>
|
||||
</UserData>
|
||||
|
||||
<UseConfigurationSet>false</UseConfigurationSet>
|
||||
|
||||
<DynamicUpdate>
|
||||
<Enable>false</Enable>
|
||||
<WillShowUI>Never</WillShowUI>
|
||||
</DynamicUpdate>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<settings pass="specialize">
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<ComputerName>{{COMPUTER_NAME}}</ComputerName>
|
||||
<TimeZone>{{TIMEZONE}}</TimeZone>
|
||||
<RegisteredOwner>CI</RegisteredOwner>
|
||||
<RegisteredOrganization>CI Lab</RegisteredOrganization>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<settings pass="oobeSystem">
|
||||
<component name="Microsoft-Windows-International-Core"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<InputLocale>{{KEYBOARD}}</InputLocale>
|
||||
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
|
||||
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
|
||||
<UserLocale>{{LOCALE_USER}}</UserLocale>
|
||||
</component>
|
||||
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<UserAccounts>
|
||||
<AdministratorPassword>
|
||||
<Value>{{ADMIN_PWD}}</Value>
|
||||
<PlainText>true</PlainText>
|
||||
</AdministratorPassword>
|
||||
</UserAccounts>
|
||||
|
||||
<AutoLogon>
|
||||
<Username>{{ADMIN_USER}}</Username>
|
||||
<Password>
|
||||
<Value>{{ADMIN_PWD}}</Value>
|
||||
<PlainText>true</PlainText>
|
||||
</Password>
|
||||
<Enabled>true</Enabled>
|
||||
<LogonCount>5</LogonCount>
|
||||
</AutoLogon>
|
||||
|
||||
<OOBE>
|
||||
<HideEULAPage>true</HideEULAPage>
|
||||
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
|
||||
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
||||
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||
<NetworkLocation>Work</NetworkLocation>
|
||||
<ProtectYourPC>3</ProtectYourPC>
|
||||
<SkipMachineOOBE>true</SkipMachineOOBE>
|
||||
<SkipUserOOBE>true</SkipUserOOBE>
|
||||
</OOBE>
|
||||
|
||||
<TimeZone>{{TIMEZONE}}</TimeZone>
|
||||
<RegisteredOwner>CI</RegisteredOwner>
|
||||
<RegisteredOrganization>CI Lab</RegisteredOrganization>
|
||||
|
||||
<FirstLogonCommands>
|
||||
<SynchronousCommand wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<CommandLine>cmd /c for %d in (C D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist %d:\post-install.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\post-install.ps1 ^> C:\Windows\Temp\post-install.log 2^>^&1</CommandLine>
|
||||
<Description>Run post-install script from autounattend CD</Description>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
Reference in New Issue
Block a user