feat(template): add deploy-win — UUP download + ISO patch + deploy WS2025

New `ci template deploy-win` builds the Windows Server 2025 template VM on
the Linux host, mirroring the legacy Deploy-WinBuild2025.ps1 ten-step flow
in Python:

- winimage/uup.py: resolve the newest (or pinned) WS2025 build via the
  uupdump API and build a cached, MS-signed ISO via the uup converter.
- winimage/autounattend.py + assets/: render autounattend.xml and the
  guest post-install.ps1 (ported from the PS heredoc; VMware Tools now
  mounted as a 2nd CD, scanned at first logon).
- winimage/iso_patch.py: xorriso repack injecting the answer files at the
  ISO root and swapping the EFI boot image to efisys_noprompt.bin
  (no "press any key" prompt — essential for an unattended headless install).
- commands/template.py: the deploy-win command + _winbuild_vmx_lines
  (the 82-key VMX), _remove_vmx_lines, _wait_install_flag, _vmrun_soft_stop.
  Acquire → patch → VMDK → VMX → install → flag wait → strip media +
  NIC e1000e→vmxnet3 → verify IP → cold snapshot.

Deterministic by build (--build), with --win-iso to bypass UUP. Tests
mirror the prepare-win mocking style; total coverage 93%.
This commit is contained in:
2026-06-08 23:21:02 +02:00
parent 976c4b1cae
commit b83a7fa624
9 changed files with 1813 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
"""Windows install-image tooling for ``ci template deploy-win``.
Linux-host implementation: acquire a Windows Server 2025 ISO from UUP dump
(``uup.py``), render the unattended answer file + guest post-install script
(``autounattend.py``), and patch the ISO with a no-prompt boot image plus those
files injected at the root (``iso_patch.py``).
The heavy / OS-specific logic lives here so ``commands/template.py`` stays a thin
orchestrator. The Windows-host port is tracked in ``plans/deploy-win-windows-host.md``.
"""
@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Windows Server 2025 unattended install template.
Double-brace placeholders are substituted by ci_orchestrator.winimage.autounattend.
Boot NIC is e1000e (broad compat). VMware Tools install brings vmxnet3 driver;
deploy-win switches the VMX to vmxnet3 after Tools install completes.
post-install.ps1 is injected at the ISO root (FirstLogonCommands scans drives
for it). VMware Tools are mounted as a 2nd CD-ROM; post-install.ps1 finds
setup64.exe on that CD.
Disk layout (UEFI/GPT): EFI 100 MB FAT32 (S:), MSR 16 MB, Primary rest NTFS (C:).
Image index {{IMAGE_INDEX}} = "Windows Server 2025 SERVERSTANDARD" (Desktop Experience).
-->
<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 ^&gt; C:\Windows\Temp\post-install.log 2^&gt;^&amp;1</CommandLine>
<Description>Run post-install script from install ISO root</Description>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
@@ -0,0 +1,308 @@
# Guest post-install for WinBuild2025 — rendered to post-install.ps1 and injected
# at the root of the patched Windows install ISO. Runs once at FirstLogon as
# Administrator (autounattend FirstLogonCommands). Hardening + WinRM + VMware
# Tools install + completion flag + shutdown.
#
# Ported from the heredoc in template/Deploy-WinBuild2025.ps1 (Step 2). Rendered
# by ci_orchestrator.winimage.autounattend; the only deploy-time placeholders are
# {{ADMIN_USER}} and {{ADMIN_PWD}}. StrictMode is intentionally omitted — this is
# a deliberately tolerant best-effort hardening script (ErrorActionPreference=Continue).
$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.
# VMware Tools are mounted as the 2nd CD-ROM (no bundled Tools\ folder): the
# scan below resolves setup64.exe/setup.exe on the Tools CD.
$selfDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$toolsExe = $null
foreach ($name in 'setup64.exe','setup.exe') {
$cand = Join-Path $selfDir "Tools\$name"
if (Test-Path $cand) { $toolsExe = $cand; break }
}
if (-not $toolsExe) {
L 'Bundled Tools not found, scanning CD drives...'
foreach ($drv in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=5')) {
foreach ($name in 'setup64.exe','setup.exe') {
$cand = Join-Path ($drv.DeviceID + '\') $name
if (Test-Path $cand) { $toolsExe = $cand; break }
}
if ($toolsExe) { break }
}
}
L "Tools setup resolved to: $toolsExe"
$localRoot = 'C:\Windows\Temp\VMwareTools'
$msiLog = 'C:\Windows\Temp\vmtools-msi.log'
$exitCode = -1
if ($toolsExe -and (Test-Path $toolsExe)) {
# Copy payload to local writable disk — wrapper extract from CD fails at
# FirstLogon phase due to user profile %TEMP% ACL bootstrap timing.
$srcRoot = Split-Path -Parent $toolsExe
if (Test-Path $localRoot) { Remove-Item $localRoot -Recurse -Force }
L "Copying Tools payload $srcRoot -> $localRoot"
Copy-Item -Path $srcRoot -Destination $localRoot -Recurse -Force
# PS argument parser strips inner quotes from /v"..."; --% stop-parsing
# token passes the tail literally to the EXE.
L "Tools cmd: .\setup.exe /s /v`"/qn REBOOT=R /l*v $msiLog`""
Push-Location $localRoot
try {
& .\setup.exe --% /s /v"/qn REBOOT=R /l*v C:\Windows\Temp\vmtools-msi.log"
$exitCode = $LASTEXITCODE
} finally { Pop-Location }
L "VMware Tools wrapper exit code: $exitCode (MSI continues async)"
} else {
L 'ERROR: VMware Tools setup not found (bundled or on any CD-ROM)'
}
# ── UAC off (admin script automation) ─────────────────────────────────────
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v EnableLUA /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f | Out-Null
L 'UAC disabled'
# ── Defender disabled (lab only) — Install-CIToolchain-WinBuild2025 skips Defender step ─
# RTP off + tamper-protect GPO. With Defender fully off, exclusion paths
# become moot (no scanner to exclude from), so Install-CIToolchain-WinBuild2025 has been
# stripped of its Defender step.
try { Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop; L 'Defender RTP off' }
catch { L "Defender RTP off failed: $($_.Exception.Message)" }
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows Defender' /v DisableAntiSpyware /t REG_DWORD /d 1 /f | Out-Null
# ── Telemetry / DiagTrack off ─────────────────────────────────────────────
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection' /v AllowTelemetry /t REG_DWORD /d 0 /f | Out-Null
sc.exe config DiagTrack start= disabled | Out-Null
sc.exe stop DiagTrack | Out-Null
L 'Telemetry disabled'
# ── Windows Update GPO locks — services left Manual (§7.2 Strategy B) ───
# Set both WU services to Manual so they can be invoked on-demand by Setup
# Step 6 (WU COM API via SYSTEM task) but won't auto-start between Deploy and Setup.
# UsoSvc defaults to Automatic on Windows Server 2025; must be explicit here.
# GPO keys below also block background triggers. Step 6b permanently disables both.
Set-Service -Name wuauserv -StartupType Manual -ErrorAction SilentlyContinue
Set-Service -Name UsoSvc -StartupType Manual -ErrorAction SilentlyContinue
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' /v DisableWindowsUpdateAccess /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null
L 'Windows Update GPO locks set + wuauserv/UsoSvc Manual (Setup Step 6b permanently disables)'
# ── Firewall: all profiles disabled + allow RDP + ICMP + WinRM HTTPS ─────
# Disabling all profiles removes any block on subsequent WinRM config and WU
# downloads. This VM lives on VMnet8 NAT behind the VMware NAT gateway — no
# inbound exposure from outside the host. Install-CIToolchain-WinBuild2025 Step 1 validates.
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
L 'Windows Firewall disabled on all profiles'
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server' /v fDenyTSConnections /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' /v UserAuthentication /t REG_DWORD /d 0 /f | Out-Null
Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue
New-NetFirewallRule -DisplayName 'ICMPv4-In' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -ErrorAction SilentlyContinue | Out-Null
L 'RDP + ICMP allowed'
# ── WinRM HTTPS-only/5986, Basic auth over HTTPS, TrustedHosts=* (lab only) ─
# AllowUnencrypted intentionally left false — all WinRM traffic uses HTTPS/5986.
Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null
winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null
try {
$cert = New-SelfSignedCertificate -DnsName $env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint $cert.Thumbprint -Force | Out-Null
New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any -RemoteAddress '192.168.79.0/24' | Out-Null
L 'WinRM HTTPS listener configured (port 5986, restricted to 192.168.79.0/24)'
} catch { L "WinRM HTTPS failed: $($_.Exception.Message)" }
winrm set winrm/config/winrs '@{MaxProcessesPerShell="100"}' | Out-Null
Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>$null
Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>$null
# Plugin-level quota: pypsrp (Linux->Win) uses Microsoft.PowerShell endpoint,
# which has its own MaxMemoryPerShellMB independent of the Shell quota above.
# Without this, parallel native processes (e.g. 3x cl.exe in MSBuild fan-out)
# fail with MSB6003 / Win32 1816 'Not enough quota'.
Set-Item 'WSMan:\localhost\Plugin\Microsoft.PowerShell\Quotas\MaxMemoryPerShellMB' 2048 -Force 3>$null
Set-Service -Name WinRM -StartupType Automatic 3>$null
Restart-Service -Name WinRM -Force 3>$null
L 'WinRM limits: Shell+Plugin MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=100; StartType=Automatic'
# ── Non-interactive desktop heap (csrss SharedSection) ───────────────────
# WinRM sessions run in Session 0 non-interactive window station; default
# desktop heap is 768 KB. CreateProcess fails with ERROR_NOT_ENOUGH_QUOTA
# (Win32 1816 -> MSB6003) when multiple compiler processes spawn in parallel.
# Bump the third SharedSection field to 4096 KB. Change is read by csrss only
# at boot — the post-install shutdown at the end of this script applies it.
$subsysKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\SubSystems'
$subsysCur = (Get-ItemProperty -Path $subsysKey -Name Windows).Windows
$subsysNew = $subsysCur -replace 'SharedSection=(\d+),(\d+),\d+', 'SharedSection=$1,$2,4096'
Set-ItemProperty -Path $subsysKey -Name Windows -Value $subsysNew
L "Desktop heap (non-interactive) SharedSection 3rd field set to 4096 KB (takes effect after reboot)"
# ── Server Manager / WAC popup / first-logon UX ──────────────────────────
# Server Manager auto-start off — machine-wide + current Administrator + Default
# profile hive (so future cloned/sysprep'd users inherit).
reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKCU\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
reg load HKU\DefaultUser 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null
reg add 'HKU\DefaultUser\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
reg unload HKU\DefaultUser 2>&1 | Out-Null
# Windows Admin Center prompt at SM launch off
reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotPopWACConsoleAtSMLaunch /t REG_DWORD /d 1 /f | Out-Null
# DisableCAD — no Ctrl+Alt+Del at logon (CI VMs never need it)
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null
# OOBE privacy experience suppressed
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null
L 'Server Manager + WAC + CAD + OOBE prompts disabled'
# Supplemental UX hardening validated by Install-CIToolchain-WinBuild2025 Step 5c:
# Lock screen: suppress at CI console sessions (no interactive user ever logs in)
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization' /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null
# Server Manager GPO policy key (authoritative path on WS2025; HKLM key above is not always honoured)
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' /v DoNotOpenAtLogon /t REG_DWORD /d 1 /f | Out-Null
# Server Manager scheduled task — the actual launcher on WS2025; registry alone insufficient
schtasks /Change /TN '\Microsoft\Windows\Server Manager\ServerManager' /Disable 2>&1 | Out-Null
# DisableCAD in Winlogon (belt-and-suspenders with Policies\System key set above)
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null
# Autologin — Administrator auto-signs in after every boot (CI template convenience).
# Password stored plaintext in registry; acceptable for isolated VMnet8 NAT lab VM.
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v AutoAdminLogon /t REG_SZ /d 1 /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultUserName /t REG_SZ /d "{{ADMIN_USER}}" /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultPassword /t REG_SZ /d "{{ADMIN_PWD}}" /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
# 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"
@@ -0,0 +1,80 @@
"""Render the WS2025 unattended answer file and guest post-install script.
Both templates ship inside the package (``winimage/assets/``) so the production
venv (installed non-editable) can find them. Placeholders are ``{{NAME}}``.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from importlib import resources
_ASSETS_PKG = "ci_orchestrator.winimage.assets"
_AUTOUNATTEND_ASSET = "autounattend.win2025.xml"
_POST_INSTALL_ASSET = "post-install.win.ps1.tmpl"
_COMPUTER_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,15}$")
_PLACEHOLDER_RE = re.compile(r"\{\{[^}]+\}\}")
@dataclass(frozen=True)
class WinUnattendParams:
"""Values substituted into the autounattend + post-install templates."""
admin_user: str = "Administrator"
admin_password: str = "WinBuild!"
computer_name: str = "WINBUILD-2025"
product_key: str = "TVRH6-WHNXV-R9WG3-9XRFY-MY832"
image_index: int = 2
locale_os: str = "en-US"
locale_user: str = "it-IT"
keyboard: str = "0410:00000410"
timezone: str = "W. Europe Standard Time"
def validate_computer_name(name: str) -> None:
"""Raise ValueError if ``name`` is not a valid NetBIOS computer name."""
if not _COMPUTER_NAME_RE.match(name):
raise ValueError(
f"invalid computer name {name!r}: must match {_COMPUTER_NAME_RE.pattern} "
"(1-15 chars, letters/digits/hyphen)"
)
def _read_asset(name: str) -> str:
return resources.files(_ASSETS_PKG).joinpath(name).read_text(encoding="utf-8")
def _apply(text: str, subs: dict[str, str]) -> str:
for key, value in subs.items():
text = text.replace(key, value)
leftover = _PLACEHOLDER_RE.search(text)
if leftover:
raise ValueError(f"unsubstituted placeholder remains: {leftover.group(0)}")
return text
def render_autounattend(params: WinUnattendParams) -> str:
"""Render the autounattend.xml answer file from ``params``."""
subs = {
"{{LOCALE_OS}}": params.locale_os,
"{{LOCALE_USER}}": params.locale_user,
"{{KEYBOARD}}": params.keyboard,
"{{IMAGE_INDEX}}": str(params.image_index),
"{{PRODUCT_KEY}}": params.product_key,
"{{COMPUTER_NAME}}": params.computer_name,
"{{TIMEZONE}}": params.timezone,
"{{ADMIN_USER}}": params.admin_user,
"{{ADMIN_PWD}}": params.admin_password,
}
return _apply(_read_asset(_AUTOUNATTEND_ASSET), subs)
def render_post_install(params: WinUnattendParams) -> str:
"""Render the guest post-install.ps1 from ``params``."""
subs = {
"{{ADMIN_USER}}": params.admin_user,
"{{ADMIN_PWD}}": params.admin_password,
}
return _apply(_read_asset(_POST_INSTALL_ASSET), subs)
+125
View File
@@ -0,0 +1,125 @@
"""Patch a Windows install ISO: inject answer files + no-prompt boot (xorriso).
Produces a single bootable ISO that (1) carries ``autounattend.xml`` and
``post-install.ps1`` at its root and (2) boots without the "Press any key to
boot from CD" prompt — essential for an unattended install, since a keypress
prompt would hang the headless VM.
Method: extract the base ISO, drop the two files at the root, then repack with
``xorriso -as mkisofs`` using ``efi/microsoft/boot/efisys_noprompt.bin`` as the
EFI El Torito boot image (the no-prompt variant Microsoft ships on the ISO).
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import click
# Boot images present on a Windows Server install ISO.
_BIOS_BOOT = "boot/etfsboot.com"
_EFI_BOOT_NOPROMPT = "efi/microsoft/boot/efisys_noprompt.bin"
def _require_xorriso() -> str:
exe = shutil.which("xorriso")
if exe is None:
raise click.ClickException("xorriso not found. Install xorriso.")
return exe
def _read_volid(base_iso: Path) -> str:
"""Read the ISO volume id (label); fall back to a sane default."""
label = ""
blkid = shutil.which("blkid")
if blkid is not None:
proc = subprocess.run(
[blkid, "-s", "LABEL", "-o", "value", str(base_iso)],
capture_output=True,
text=True,
check=False,
)
label = proc.stdout.strip()
return (label or "WINSERVER")[:32]
def _check_free_space(base_iso: Path, work_parent: Path) -> None:
"""Ensure ~2.2x the base ISO size is free (extract tree + output ISO)."""
needed = int(base_iso.stat().st_size * 2.2)
free = shutil.disk_usage(work_parent).free
if free < needed:
raise click.ClickException(
f"insufficient space to patch ISO: need ~{needed // 2**30} GiB free "
f"under {work_parent}, have {free // 2**30} GiB."
)
def patch_iso(
base_iso: Path,
*,
autounattend_xml: str,
post_install_ps1: str,
out_iso: Path,
work_dir: Path | None = None,
) -> Path:
"""Inject answer files + no-prompt boot into ``base_iso`` → ``out_iso``."""
xorriso = _require_xorriso()
if not base_iso.is_file():
raise click.ClickException(f"base ISO not found: {base_iso}")
work_parent = (work_dir or out_iso.parent)
work_parent.mkdir(parents=True, exist_ok=True)
_check_free_space(base_iso, work_parent)
tree = work_parent / f".isopatch-{out_iso.stem}"
if tree.exists():
shutil.rmtree(tree)
tree.mkdir(parents=True)
try:
click.echo(f" [iso] extracting base ISO: {base_iso.name}")
subprocess.run(
[xorriso, "-osirrox", "on", "-indev", str(base_iso), "-extract", "/", str(tree)],
check=True,
)
# Extracted files are read-only; make the tree writable so we can add files.
subprocess.run(["chmod", "-R", "u+w", str(tree)], check=True)
efi_boot = tree / _EFI_BOOT_NOPROMPT
if not efi_boot.is_file():
raise click.ClickException(
f"{_EFI_BOOT_NOPROMPT} missing from ISO — cannot build a no-prompt "
"boot image (is this a Windows Server install ISO?)."
)
(tree / "autounattend.xml").write_text(autounattend_xml, encoding="utf-8")
(tree / "post-install.ps1").write_text(post_install_ps1, encoding="utf-8")
volid = _read_volid(base_iso)
if out_iso.exists():
out_iso.unlink()
click.echo(f" [iso] repacking no-prompt ISO: {out_iso.name} (volid {volid})")
argv = [
xorriso,
"-as", "mkisofs",
"-iso-level", "4",
"-J", "-joliet-long", "-D", "-R",
"-V", volid,
"-b", _BIOS_BOOT,
"-no-emul-boot", "-boot-load-size", "8", "-boot-info-table",
"-eltorito-alt-boot",
"-e", _EFI_BOOT_NOPROMPT, "-no-emul-boot",
"-o", str(out_iso),
str(tree),
]
subprocess.run(argv, check=True)
finally:
shutil.rmtree(tree, ignore_errors=True)
if not out_iso.is_file():
raise click.ClickException(f"xorriso produced no output ISO: {out_iso}")
click.echo(f" [iso] patched ISO ready: {out_iso}")
return out_iso
+233
View File
@@ -0,0 +1,233 @@
"""Acquire a Windows Server 2025 install ISO from UUP dump (Linux host).
UUP dump is deterministic on its *input*: a build is pinned by build number and
the component files come MS-signed from the official Windows Update CDN. The
final ISO is assembled locally (its hash is not canonical), so we cache the
result per build number under ``<CI_ROOT>/iso/ws2025-<build>.iso``.
Flow (mirrors what uupdump.net's generated package does):
1. ``resolve_build`` — query the uupdump API for Windows Server 2025 builds and
pick the newest (or match a pinned build string).
2. ``build_iso`` — download the per-set conversion package, run its
``uup_download_linux.sh`` (aria2 download + wimlib conversion), then move the
produced ISO into the cache.
The downloader requires ``aria2c``, ``cabextract``, ``wimlib-imagex``,
``chntpw`` and ``genisoimage``/``mkisofs`` on PATH (installed by
``setup-host-linux.sh``).
"""
from __future__ import annotations
import json
import shutil
import subprocess
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import click
API_BASE = "https://api.uupdump.net"
GET_URL = "https://uupdump.net/get.php"
_USER_AGENT = "ci-orchestrator/1.0 (+deploy-win)"
#: Tools the UUP → ISO conversion needs (uup_download_linux.sh checks these).
REQUIRED_TOOLS = ("aria2c", "cabextract", "wimlib-imagex", "chntpw")
#: Title prefix that identifies a Windows Server 2025 set (excludes "version 23H2").
_WS2025_PREFIX = "Windows Server 2025"
@dataclass(frozen=True)
class UupBuild:
"""A UUP dump build set."""
uuid: str
build: str # e.g. "26100.32860"
title: str
arch: str
def _http_json(url: str, *, timeout: float = 60.0) -> dict[str, Any]:
"""GET ``url`` and parse the JSON body."""
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT, "Accept": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
data: Any = json.loads(raw)
if not isinstance(data, dict):
raise click.ClickException(f"uupdump API returned unexpected JSON from {url}")
return data
def _build_sort_key(build: str) -> tuple[int, ...]:
"""Numeric sort key for a build string like ``26100.32860``."""
parts: list[int] = []
for chunk in build.split("."):
try:
parts.append(int(chunk))
except ValueError:
parts.append(0)
return tuple(parts)
def list_ws2025_builds(*, arch: str = "amd64", timeout: float = 60.0) -> list[UupBuild]:
"""Return all Windows Server 2025 builds for ``arch``, newest first."""
url = f"{API_BASE}/listid.php?search={urllib.parse.quote(_WS2025_PREFIX)}"
data = _http_json(url, timeout=timeout)
builds_raw = data.get("response", {}).get("builds", {})
if not isinstance(builds_raw, dict):
raise click.ClickException("uupdump API: missing 'builds' in listid response")
out: list[UupBuild] = []
for entry in builds_raw.values():
if not isinstance(entry, dict):
continue
title = str(entry.get("title", ""))
if not title.startswith(_WS2025_PREFIX):
continue
if str(entry.get("arch", "")) != arch:
continue
out.append(
UupBuild(
uuid=str(entry["uuid"]),
build=str(entry["build"]),
title=title,
arch=arch,
)
)
out.sort(key=lambda b: _build_sort_key(b.build), reverse=True)
return out
def resolve_build(
*, build: str | None = None, arch: str = "amd64", timeout: float = 60.0
) -> UupBuild:
"""Resolve the newest WS2025 build, or the one matching ``build`` if pinned."""
builds = list_ws2025_builds(arch=arch, timeout=timeout)
if not builds:
raise click.ClickException(
f"No Windows Server 2025 ({arch}) builds found on uupdump."
)
if build is None:
return builds[0]
for b in builds:
if b.build == build:
return b
available = ", ".join(b.build for b in builds)
raise click.ClickException(
f"WS2025 build {build!r} not found on uupdump. Available: {available}"
)
def check_tools() -> list[str]:
"""Return the list of missing conversion tools (empty when all present)."""
missing = [t for t in REQUIRED_TOOLS if shutil.which(t) is None]
if shutil.which("genisoimage") is None and shutil.which("mkisofs") is None:
missing.append("genisoimage|mkisofs")
return missing
def cache_path(dest_dir: Path, build: UupBuild) -> Path:
"""Path of the cached ISO for ``build`` under ``dest_dir``."""
return dest_dir / f"ws2025-{build.build}.iso"
def _download_package(build: UupBuild, lang: str, edition: str, dest: Path, *, timeout: float) -> None:
"""Download the uupdump conversion package zip for ``build``."""
url = (
f"{GET_URL}?id={build.uuid}"
f"&pack={urllib.parse.quote(lang)}"
f"&edition={urllib.parse.quote(edition)}"
f"&autodl=2"
)
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
with urllib.request.urlopen(req, timeout=timeout) as resp, dest.open("wb") as fh:
shutil.copyfileobj(resp, fh)
if dest.stat().st_size < 1024:
raise click.ClickException(f"uupdump package download too small: {dest}")
def build_iso(
build: UupBuild,
*,
dest_dir: Path,
lang: str = "en-us",
edition: str = "SERVERSTANDARD",
work_dir: Path | None = None,
keep_work: bool = False,
) -> Path:
"""Build (or reuse a cached) WS2025 ISO for ``build`` and return its path.
Downloads the uupdump conversion package, runs its ``uup_download_linux.sh``
(aria2 download of the MS-signed UUP set + wimlib conversion to ISO), then
moves the produced ISO to ``<dest_dir>/ws2025-<build>.iso``.
"""
dest_dir.mkdir(parents=True, exist_ok=True)
cached = cache_path(dest_dir, build)
if cached.is_file():
click.echo(f" [uup] cached ISO present, reusing: {cached}")
return cached
missing = check_tools()
if missing:
raise click.ClickException(
"Missing UUP conversion tools: "
+ ", ".join(missing)
+ ". Install them (setup-host-linux.sh installs aria2 wimtools "
"cabextract chntpw genisoimage)."
)
work = work_dir or (dest_dir / f".uup-{build.build}")
if work.exists():
shutil.rmtree(work)
work.mkdir(parents=True)
pkg = work / "uup-package.zip"
click.echo(f" [uup] downloading conversion package for build {build.build} ({build.uuid})")
_download_package(build, lang, edition, pkg, timeout=120.0)
with zipfile.ZipFile(pkg) as zf:
zf.extractall(work)
script = work / "uup_download_linux.sh"
if not script.is_file():
raise click.ClickException(f"uup_download_linux.sh missing from package: {work}")
script.chmod(0o755)
click.echo(" [uup] running converter (downloads ~5 GB + builds ISO; this is slow) ...")
proc = subprocess.run(
["bash", str(script)],
cwd=str(work),
check=False,
)
if proc.returncode != 0:
raise click.ClickException(
f"UUP conversion failed (exit {proc.returncode}). See {work}/aria2_download.log."
)
produced = _find_produced_iso(work)
if produced is None:
raise click.ClickException(f"UUP conversion produced no ISO under {work}")
shutil.move(str(produced), str(cached))
click.echo(f" [uup] ISO ready: {cached}")
if not keep_work:
shutil.rmtree(work, ignore_errors=True)
return cached
def _find_produced_iso(work: Path) -> Path | None:
"""Find the ISO the converter produced (largest *.iso, excluding the package)."""
candidates = [
p
for p in work.rglob("*.iso")
if p.name != "uup-package.zip" and p.is_file()
]
candidates += [p for p in work.rglob("*.ISO") if p.is_file()]
if not candidates:
return None
return max(candidates, key=lambda p: p.stat().st_size)