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:
Simone
2026-05-10 01:01:43 +02:00
parent ab636a0ec1
commit 96b65d40de
3 changed files with 1324 additions and 38 deletions
+191 -38
View File
@@ -1,14 +1,13 @@
# TODO — Local CI/CD System # TODO — Local CI/CD System
<!-- Last updated: 2026-05-09TODO+CONSIGLI uniti, priorità P0..P3 + rationale per ogni voce aperta --> <!-- Last updated: 2026-05-10 — line refs e wording allineati al refactor 2026-05-09 -->
> Documento unico di lavoro: roadmap, audit trail dei task completati, e backlog post-v1.0 > Documento unico di lavoro: roadmap, audit trail dei task completati, e backlog post-v1.0
> con priorità e razionale (assorbito da `CONSIGLI.md`). Le voci aperte sono raggruppate > con priorità e razionale. Le voci aperte sono raggruppate per area e marcate **P0..P3**:
> per area e marcate **P0..P3**:
> >
> **Doc di setup operativi**: > **Doc di setup operativi**:
> - [docs/HOST-SETUP.md](docs/HOST-SETUP.md) — bootstrap macchina host (Setup-Host.ps1) > - [docs/HOST-SETUP.md](docs/HOST-SETUP.md) — bootstrap macchina host (Setup-Host.ps1)
> - [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md) — provisioning template Windows (Prepare + Setup-WinBuild2025) > - [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md) — provisioning template Windows (Deploy + Prepare + Setup-WinBuild2025)
> - [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md) — bozza/TODO template Linux (non implementato) > - [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md) — bozza/TODO template Linux (non implementato)
> >
> - **P0** — sicurezza o affidabilità: affrontare prima di estendere il sistema > - **P0** — sicurezza o affidabilità: affrontare prima di estendere il sistema
@@ -55,7 +54,7 @@
### Fase A — Crea e installa la VM (NIC: NAT per internet) ### Fase A — Crea e installa la VM (NIC: NAT per internet)
- [x] Crea nuova VM in VMware Workstation con queste impostazioni: - [x] Crea nuova VM in VMware Workstation con queste impostazioni:
- 4 vCPU, 6144 MB RAM, disco 80 GB thin - 4 vCPU, 6144 MB RAM, disco 80 GB thin
- VMX path: `F:\CI\Templates\WinBuild\WinBuild.vmx` - VMX path: `F:\CI\Templates\WinBuild\CI-WinBuild.vmx`
- **NIC: VMnet8 (NAT)** ← internet necessario per Windows Update e installer - **NIC: VMnet8 (NAT)** ← internet necessario per Windows Update e installer
- CD-ROM: `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` - CD-ROM: `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso`
- [x] Installa Windows Server 2025 dall'ISO - [x] Installa Windows Server 2025 dall'ISO
@@ -150,7 +149,7 @@
## 1. Sicurezza & hardening ## 1. Sicurezza & hardening
### 1.1 [P0] Migrare WinRM da HTTP/Basic a HTTPS/5986 ### 1.1 [P0] Migrare WinRM da HTTP/Basic a HTTPS/5986
File: [template/Setup-WinBuild2025.ps1:179-198](template/Setup-WinBuild2025.ps1), [scripts/Invoke-RemoteBuild.ps1:82-91](scripts/Invoke-RemoteBuild.ps1). File: [template/Setup-WinBuild2025.ps1:204-241](template/Setup-WinBuild2025.ps1) (Step 3 WinRM), [template/Deploy-WinBuild2025.ps1:524-534](template/Deploy-WinBuild2025.ps1) (Enable-PSRemoting + AllowUnencrypted/Basic), [scripts/Invoke-RemoteBuild.ps1:83-91](scripts/Invoke-RemoteBuild.ps1), [scripts/Get-BuildArtifacts.ps1:66-75](scripts/Get-BuildArtifacts.ps1).
**Motivazione**: la combo `AllowUnencrypted=true` + `Auth/Basic=true` invia credenziali del **Motivazione**: la combo `AllowUnencrypted=true` + `Auth/Basic=true` invia credenziali del
`ci_build` (admin) in chiaro su VMnet8. Anche in lab isolato, l'host può essere compromesso `ci_build` (admin) in chiaro su VMnet8. Anche in lab isolato, l'host può essere compromesso
@@ -164,16 +163,20 @@ manca solo l'esecuzione.
- [ ] Tenere `-SkipCACheck` lato host: per cert lab self-signed è accettabile, ma documentare la motivazione inline. - [ ] Tenere `-SkipCACheck` lato host: per cert lab self-signed è accettabile, ma documentare la motivazione inline.
- [ ] Regole firewall — limitare WinRM alla subnet build VM (`192.168.79.0/24` — VMnet8 NAT). - [ ] Regole firewall — limitare WinRM alla subnet build VM (`192.168.79.0/24` — VMnet8 NAT).
### 1.2 [P0] Restringere `TrustedHosts` lato host ### 1.2 [P0] Restringere `TrustedHosts` lato host (audit-only — già coperto)
File: probabile `Setup-Host.ps1` (e in vari run setup) — pattern `*` è incompatibile con altri carichi sull'host Windows 11. File: nessuno script imposta `*` sull'host. Stato corrente:
- `Setup-Host.ps1` non tocca TrustedHosts (verificato 2026-05-10)
- [template/Prepare-WinBuild2025.ps1:270-294](template/Prepare-WinBuild2025.ps1:270) appende `$VMIPAddress` e ripristina nel `finally` (riga 617)
- [docs/HOST-SETUP.md:121-126](docs/HOST-SETUP.md:121) raccomanda `'192.168.79.*'` in setup manuale
Sostituire con la subnet build: **Azione residua**: audit eventuali host già configurati con `*` (eredità manuale) e
sostituire con la subnet build:
```powershell ```powershell
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate
``` ```
### 1.3 [P0] Pinning hash SHA256 degli installer ### 1.3 [P0] Pinning hash SHA256 degli installer
File: [template/Setup-WinBuild2025.ps1:332-399](template/Setup-WinBuild2025.ps1), [template/Setup-WinBuild2025.ps1:401-502](template/Setup-WinBuild2025.ps1). File: [template/Setup-WinBuild2025.ps1:707-755](template/Setup-WinBuild2025.ps1) (Step 8 Python), [template/Setup-WinBuild2025.ps1:757-885](template/Setup-WinBuild2025.ps1) (Step 9 VS Build Tools), [template/Setup-WinBuild2025.ps1:660-705](template/Setup-WinBuild2025.ps1) (Step 7 dotnet-install.ps1).
**Motivazione**: Python, `dotnet-install.ps1`, `vs_buildtools.exe` vengono scaricati senza **Motivazione**: Python, `dotnet-install.ps1`, `vs_buildtools.exe` vengono scaricati senza
verifica integrità. Un MITM nella rete dell'host (anche temporaneo) può iniettare binari verifica integrità. Un MITM nella rete dell'host (anche temporaneo) può iniettare binari
@@ -200,16 +203,24 @@ $ipv4 = '^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?
Estrarre la costante in un modulo condiviso `scripts/_Common.psm1` per evitare drift fra i 4 file (vedi §5.2). Estrarre la costante in un modulo condiviso `scripts/_Common.psm1` per evitare drift fra i 4 file (vedi §5.2).
### 1.5 [P1] PAT mai persistito quando si abilita `-UseGitClone` ### 1.5 [P1] PAT mai persistito quando si abilita `-UseGitClone`
Quando arriva il momento di implementare il clone in-VM (vedi §3.3 / sezione "In-VM Git Clone"): Requisiti di sicurezza per l'implementazione del clone in-VM. La sequenza operativa
- [ ] Recuperare il PAT da Credential Manager dell'host **subito prima** della WinRM session, mai prima. (quando, dove, come) è dettagliata nella sezione "In-VM Git Clone (Ottimizzazione) — riferimento §3.3".
- [ ] Iniettare via `$using:cred` in `Invoke-Command` e cancellare la variabile in un `finally` interno alla scriptblock.
- [ ] Aggiungere un grep automatico nei log job: se il PAT compare grezzo, fallire la build. Vincoli da rispettare in qualsiasi implementazione di `-UseGitClone`:
- [ ] PAT recuperato da Credential Manager **subito prima** della WinRM session, mai prima.
- [ ] Iniettato via `$using:cred` o env var di sessione, mai in argv né in log/transcript.
- [ ] Cancellato in `finally` interno alla scriptblock guest.
- [ ] **Grep automatico post-job sui log**: se il PAT compare grezzo in `$jobLog` o
`transcript.txt`, marcare la build come fallita e ruotare il PAT (regola di safety net,
non sostituisce le precedenti).
### 1.6 [P2] Defender + Firewall + UAC tutti disattivati nel template — documentare il modello di minaccia ### 1.6 [P2] Defender + Firewall + UAC tutti disattivati nel template — documentare il modello di minaccia
File: [template/Setup-WinBuild2025.ps1:200-329](template/Setup-WinBuild2025.ps1). File: [template/Deploy-WinBuild2025.ps1:485-551](template/Deploy-WinBuild2025.ps1) (UAC, ServerManager, DisableCAD, OOBE — set da Deploy), [template/Setup-WinBuild2025.ps1:176-336](template/Setup-WinBuild2025.ps1) (Firewall Step 1, Defender Step 2 validation-only post-refactor 2026-05-09, WinRM Step 3, User Step 4, UAC Step 4b, Dirs Step 5).
Attualmente: `Set-NetFirewallProfile ... -Enabled False`, `EnableLUA=0`, `DisableAntiSpyware=1`, Stato attuale (post-refactor §7):
`DisableRealtimeMonitoring=1`, `LocalAccountTokenFilterPolicy=1`. - **Defender**: disabled da Deploy (`DisableAntiSpyware=1` GPO + RTP off) — Setup Step 2 è validation-only
- **Firewall**: disabled da Setup Step 1 (`Set-NetFirewallProfile ... -Enabled False`); §7.1 prevede spostamento a Deploy
- **UAC**: `EnableLUA=0`, `LocalAccountTokenFilterPolicy=1` — duplicato Deploy + Setup Step 4b (§7.1 lo riduce a Assert-Step in Setup)
Le scelte sono ragionevoli per un template lab efimero, ma vanno raccolte in un'unica sezione Le scelte sono ragionevoli per un template lab efimero, ma vanno raccolte in un'unica sezione
`BEST-PRACTICES.md §X — Threat Model & Hardening Trade-offs` che indichi: `BEST-PRACTICES.md §X — Threat Model & Hardening Trade-offs` che indichi:
@@ -237,6 +248,9 @@ senza tracciamento.
### 2.1 [P0] Race su IP allocation con `capacity: 4` ### 2.1 [P0] Race su IP allocation con `capacity: 4`
File: [runner/config.yaml:17](runner/config.yaml), [scripts/Invoke-CIJob.ps1:244-253](scripts/Invoke-CIJob.ps1). File: [runner/config.yaml:17](runner/config.yaml), [scripts/Invoke-CIJob.ps1:244-253](scripts/Invoke-CIJob.ps1).
**Stato osservato**: race non riprodotta in e2e-008/009 (un job alla volta). `capacity: 4`
resta attivo in produzione — bug teorico finché non si esegue stress test parallelo.
**Motivazione**: con 4 job paralleli e DHCP VMnet8, due VM possono ottenere lo stesso IP se **Motivazione**: con 4 job paralleli e DHCP VMnet8, due VM possono ottenere lo stesso IP se
la lease pool è stretta o se due `vmrun start` rispondono troppo vicini. la lease pool è stretta o se due `vmrun start` rispondono troppo vicini.
`getGuestIPAddress` ritorna l'IP visto dalla VM stessa, quindi la collisione non viene `getGuestIPAddress` ritorna l'IP visto dalla VM stessa, quindi la collisione non viene
@@ -337,7 +351,8 @@ $env:PIP_CACHE_DIR = '\\vmware-host\Shared Folders\pip-cache'
Dopo lo snapshot refresh la cache va riscaldata una volta — accettabile. Dopo lo snapshot refresh la cache va riscaldata una volta — accettabile.
### 3.2 [P1] Sostituire `Compress-Archive` con 7-Zip o robocopy ### 3.2 [P1] Sostituire `Compress-Archive` con 7-Zip o robocopy
File: [scripts/Invoke-RemoteBuild.ps1:109-122](scripts/Invoke-RemoteBuild.ps1). File: [scripts/Invoke-RemoteBuild.ps1:112,148,185](scripts/Invoke-RemoteBuild.ps1) (3 chiamate `Compress-Archive`).
Prerequisito: 7-Zip nel template — vedi §6.6 e sezione "In-VM Git Clone".
**Motivazione**: `Compress-Archive` è single-threaded e su repo medio-grandi (>100 MB sorgente) **Motivazione**: `Compress-Archive` è single-threaded e su repo medio-grandi (>100 MB sorgente)
può prendere 20-40 s. può prendere 20-40 s.
@@ -354,7 +369,8 @@ può prendere 20-40 s.
Misurare con un repo reale: probabile risparmio 10-20 s/build su `nsis-plugin-nsinnounp`. Misurare con un repo reale: probabile risparmio 10-20 s/build su `nsis-plugin-nsinnounp`.
### 3.3 [P2] In-VM clone (`-UseGitClone`) ### 3.3 [P2] In-VM clone (`-UseGitClone`)
Implementazione disegnata di seguito (sezione "In-VM Git Clone Ottimizzazione"). Implementazione disegnata di seguito (sezione "In-VM Git Clone (Ottimizzazione)").
Vincoli sicurezza PAT: vedi §1.5. Tool prerequisiti (Git, 7-Zip): vedi §6.6.
Beneficio reale solo per repo > 200 MB con submoduli grandi; misurare prima. Beneficio reale solo per repo > 200 MB con submoduli grandi; misurare prima.
### 3.4 [P3] Pre-warm pool di cloni ### 3.4 [P3] Pre-warm pool di cloni
@@ -481,7 +497,7 @@ e centralizza retry/log.
File: [gitea/workflows/lint.yml](gitea/workflows/lint.yml) (già esiste). File: [gitea/workflows/lint.yml](gitea/workflows/lint.yml) (già esiste).
Aggiungere regole specifiche progetto (`PSScriptAnalyzerSettings.psd1` in root): Aggiungere regole specifiche progetto (`PSScriptAnalyzerSettings.psd1` in root):
- `PSAvoidUsingPlainTextForPassword` — rilevante per `Setup-WinBuild2025.ps1:55`. - `PSAvoidUsingPlainTextForPassword` — rilevante per [Setup-WinBuild2025.ps1:123](template/Setup-WinBuild2025.ps1:123) (`[string] $BuildPassword``ConvertTo-SecureString -AsPlainText` riga 253).
- `PSAvoidUsingInvokeExpression`. - `PSAvoidUsingInvokeExpression`.
- `PSUseShouldProcessForStateChangingFunctions`. - `PSUseShouldProcessForStateChangingFunctions`.
- Regole custom: vietare hardcoded `F:\CI\` fuori da config (forzare param/env). - Regole custom: vietare hardcoded `F:\CI\` fuori da config (forzare param/env).
@@ -545,6 +561,153 @@ runs-on: ${{ matrix.target }}-build
Per chiavi di firma (Authenticode, GPG), usare i Gitea secrets nel workflow e passarli a Per chiavi di firma (Authenticode, GPG), usare i Gitea secrets nel workflow e passarli a
`Invoke-CIJob.ps1` come param + env, mai loggati. Stesso modello di §1.5 per il PAT. `Invoke-CIJob.ps1` come param + env, mai loggati. Stesso modello di §1.5 per il PAT.
### 6.6 [P2] Toolchain Tier-1 in Setup-WinBuild2025
File: [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
**Motivazione**: toolchain attuale (`.NET SDK`, `Python`, `VS Build Tools`) copre solo C#/Python/MSBuild.
Aggiungere tools generici-CI riduce il bisogno di installazione ad-hoc nei workflow e abilita
build più ampi senza toccare il template.
Tool da aggiungere come step `Setup-WinBuild2025` (ognuno con `Assert-Step`):
| Tool | Versione | Razionale |
| ------------------- | ----------- | ------------------------------------------------------------ |
| **Git for Windows** | latest LTS | Self-clone in VM (alt a host-zip-transfer), submodule fetch |
| **7-Zip** | latest | Universale unpack/zip; molti installer e workflow ne dipendono |
| **PowerShell 7.x** | latest LTS | Più veloce di 5.1, parallel pipelines, operatori moderni |
| **NSIS** | latest | Test build installer (es. `nsis-plugin-nsinnounp`) |
| **CMake** | latest | Build system cross-platform per progetti C/C++ nativi |
| **Node.js LTS** | latest LTS | Frontend/Electron pipelines, JS toolchain (npm) |
| **WiX Toolset** | v4 stable | MSI building da .NET projects |
| **gh CLI** | latest | Upload artifact a release Gitea/GitHub, comment PR |
| **Sysinternals** | sysinternals.com `live` | Diagnostica process/handle quando build flaky |
| **vcpkg** | latest | Package manager C++ per dipendenze native |
**Approccio**:
- Pinning hash SHA256 per ogni installer (vedi §1.3)
- Convenzione path guest: tool in `C:\BuildTools\<tool>\` (separato da `C:\CI\` runtime
`C:\CI\` resta per artefatti job e worker temp; `C:\BuildTools\` per software statico).
Documentare in `docs/BEST-PRACTICES.md` prima di implementare.
- Aggiungere a `PATH` machine-wide
- Defender già off → no scan overhead durante install
- Step esegue in seriale dopo `Toolchain cross-check` esistente, prima di `Cleanup`
- `Final pre-snapshot gate`: aggiungere check `where.exe` per ogni tool
- Cross-link: Git for Windows e 7-Zip sono richiesti anche da §3.2 e dalla sezione
"In-VM Git Clone" — implementare qui per evitare doppio lavoro
**Validazione per-tool**:
- Eseguibile risolvibile via `where.exe`
- Versione exact match al param `-<Tool>Version`
- (per gh) `gh --version` returns 0
- (per vcpkg) `vcpkg.exe version`
**Param da aggiungere** a Setup + Prepare:
- `-GitVersion`, `-7ZipVersion`, `-Pwsh7Version`, `-NSISVersion`, `-CMakeVersion`,
`-NodeJSVersion`, `-WiXVersion`, `-GhCliVersion`, `-VcpkgRev` (rev SHA del repo)
**Tier-2 (opzionali, separati)**: Java JDK, Maven/Gradle, Rust, Go, LLVM, Docker, Azure/AWS CLI, WinDbg.
Non includere in Setup base — workflow-level via `choco install` o download diretto.
---
## 7. Refactor confine Deploy ↔ Setup
**Obiettivo**: separazione netta delle responsabilità — Deploy produce **template OS**
stand-alone, Setup fa **build customization** (user, dirs, toolchain). Eliminare drift e
duplicazioni; ogni concern ha **una sola** sorgente di verità.
**Stato attuale (duplicati)**: UAC, Explorer LaunchTo, Server Manager off, DisableCAD,
OOBE telemetry, WU services disable — entrambi gli script li toccano.
### 7.1 [P1] Spostare base OS hardening da Setup a Deploy
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
**Da MUOVERE Setup → Deploy**:
| Concern Setup | Step attuale Setup | Azione Deploy |
| ----------------------------- | ------------------ | ------------------------------------------- |
| Firewall off all profiles | Step 1 | Aggiungere `Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False` in post-install (ora Deploy aggiunge solo regole RDP/ICMP) |
| WinRM tuning memoria/timeout | Step 3 (parziale) | Aggiungere `MaxMemoryPerShellMB=2048`, `IdleTimeOut=7200000`, `MaxProcessesPerShell=25` in post-install dopo Enable-PSRemoting |
**Da CONVERTIRE in Setup a Assert-Step (validation-only)**:
| Step Setup | Diventa |
| ---------- | ------- |
| Step 1 Firewall | `Assert-Step` 3 profili Enabled=False (richiede prima la riga "Da MUOVERE" → Deploy: Set-NetFirewallProfile -Enabled False) |
| Step 2 Defender | Già validation-only (refactor 2026-05-09) |
| Step 3 WinRM tuning | `Assert-Step` MaxMemoryPerShellMB, IdleTimeOut, AllowUnencrypted, Basic |
| Step 4b UAC | `Assert-Step` EnableLUA=0, LocalAccountTokenFilterPolicy=1 |
| Step 5b Explorer LaunchTo | `Assert-Step` HKCU LaunchTo=1 |
| Step 5c Server Manager / DisableCAD / OOBE | `Assert-Step` HKLM machine policy + DisableCAD=1 + OOBE policy |
**Da MANTENERE in Setup (build env, non base OS)**:
- Step 4: build user `ci_build` + admin
- Step 5: `C:\CI\{build,output,scripts}` dirs
- Step 7-9: .NET SDK, Python, VS Build Tools
- Step 10: Toolchain cross-check
- Cleanup, Final pre-snapshot gate
**Test acceptance**:
- Deploy stand-alone produce VM con tutte le validation Setup che passano (no Setup run)
- Setup run dopo Deploy: tutte Assert-Step pass `[OK]` immediato (no work)
- Test `--Skip` orthogonali (no regressione)
### 7.2 [P1] WU lifecycle — strategia B (Deploy: GPO + Manual; Setup: lifecycle)
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
**Stato attuale (conflitto ordering)**:
- Deploy: `sc.exe config wuauserv start= disabled` + `sc.exe stop wuauserv` (idem UsoSvc, WaaSMedicSvc) + GPO `NoAutoUpdate=1`
- Setup Step 6: necessita servizi UP per SchTask SYSTEM run WU
- Setup Step 6b: re-disabilita servizi → doppio lavoro, fragile
**Strategia B — separazione pulita**:
**Deploy** (post-install.ps1):
- Lascia servizi WU `start=demand` (Manual, default Windows) — NON `disabled`, NON `stop`
- Setta solo GPO `NoAutoUpdate=1`, `DisableWindowsUpdateAccess=1` → no background scheduled WU
- Risultato: WU è invocabile on-demand ma non parte mai da solo
**Setup** (Step 6 / 6b):
- Step 6 (skip se `-SkipWindowsUpdate`): `sc.exe config wuauserv start= demand` (idempotent), avvia SchTask SYSTEM, attende exit code, parse 0/2/3/3010
- Step 6b (sempre, anche con `-Skip`): `sc.exe config wuauserv start= disabled` + `sc.exe stop wuauserv` (idem UsoSvc, WaaSMedicSvc), conferma GPO presenti — snapshot capture WU permanently off
**Pattern run periodico patches** (allineato §2.5 snapshot versionato):
- Re-deploy template `BaseClean_<yyyyMMdd>` ogni N mesi via Deploy + Prepare (no `-Skip`) → patch applicate al template fresh
**Validazione finale** (Setup Final gate):
- `wuauserv.StartType == Disabled`
- `UsoSvc.StartType == Disabled`
- GPO `NoAutoUpdate=1`, `DisableWindowsUpdateAccess=1`
**Test acceptance**:
- Deploy stand-alone: `Get-Service wuauserv | Select StartType``Manual`, `Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate` → 1
- Setup `-SkipWindowsUpdate`: Final gate vede `StartType=Disabled` (impostato da Step 6b)
- Setup senza `-Skip`: WU runs, exit 0/2/3, poi servizi disabled
### 7.3 [P2] Update header docs di entrambi gli script
- [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1) `.DESCRIPTION`:
enumerare tutte le hardening voci che Deploy ora possiede (firewall, WinRM tuning,
WU GPO non disable)
- [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1) `.DESCRIPTION`:
marcare Step 1/3/4b/5b/5c come "validation-only — set by Deploy"
- [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md): aggiornare descrizione
"Cosa fa Setup-WinBuild2025" — riflettere nuovo confine
### 7.4 [P2] Test e2e refactor
- Run Deploy stand-alone su VM test → verifica tutte le hardening voci presenti
(`reg query` per chiavi UAC/OOBE/Server Manager, `Get-NetFirewallProfile`, `Get-Service WMUauserv`)
- Run Prepare-WinBuild2025 dopo Deploy → tutti `Assert-Step` pass `[OK]` senza work
- Run Prepare con `-SkipWindowsUpdate` → comportamento invariato
- Run Prepare senza `-Skip` → WU runs, services lifecycled correttamente
### 7.5 [P3] Rinomina Setup → Setup-CITools (opzionale, futuro)
Una volta che Setup fa solo build customization, nome `Setup-WinBuild2025` è impreciso —
non setup-a la build VM, **estende** un template OS già pronto con toolchain CI.
Rinominare in `Setup-CITools.ps1` o `Install-CIToolchain.ps1` rifletterebbe meglio la
responsabilità ridotta. Tracciare in TODO post-7.1/7.2.
--- ---
## In-VM Git Clone (Ottimizzazione) — riferimento §3.3 ## In-VM Git Clone (Ottimizzazione) — riferimento §3.3
@@ -574,28 +737,18 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
### Task ### Task
- [ ] **Setup-WinBuild2025.ps1 — installare Git for Windows** - [ ] **Tool prerequisiti nel template** — implementati in §6.6 (Tier-1 Toolchain):
- [ ] Scaricare e installare Git for Windows (installer silenzioso da git-scm.com) - Git for Windows + 7-Zip + gh CLI installati con hash pinning e `Assert-Step`
```powershell - Rimuovere il commento "Git is NOT installed" dal docblock di Setup-WinBuild2025.ps1
$gitUrl = 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/Git-2.47.1-64-bit.exe' una volta applicato §6.6
Start-Process $gitUrl '/VERYSILENT /NORESTART /COMPONENTS=gitlfs' -Wait - curl.exe: già presente in Windows Server 2025 — verificare con `where.exe curl`
```
- [ ] Aggiungere `C:\Program Files\Git\cmd` al PATH di sistema nella VM
- [ ] Verificare: `git --version` ritorna exit 0 nella VM
- [ ] Rimuovere il commento "Git is NOT installed" dal docblock di Setup-WinBuild2025.ps1
- [ ] **Setup-WinBuild2025.ps1 — altri tool utili**
- [ ] **7-Zip**: installer silenzioso — molto più veloce di `Compress-Archive`/`Expand-Archive`
per archivi grandi (es. npm/NuGet cache warm). Necessario anche per §3.2.
- [ ] **curl.exe**: già presente in Windows 11/Server 2025 (v7.x) — verificare disponibilità
- [ ] Valutare: `jq` (parsing JSON in script PowerShell o bash) — opzionale
- [ ] **Invoke-CIJob.ps1 — aggiungere switch `-UseGitClone`** - [ ] **Invoke-CIJob.ps1 — aggiungere switch `-UseGitClone`**
- [ ] Quando `-UseGitClone` è attivo: - [ ] Quando `-UseGitClone` è attivo:
- Saltare Phase 1 (host git clone) e la creazione del `HostSourceDir` temporaneo - Saltare Phase 1 (host git clone) e la creazione del `HostSourceDir` temporaneo
- Dopo Wait-VMReady, leggere PAT da Windows Credential Manager sull'host - Dopo Wait-VMReady, leggere PAT da Windows Credential Manager sull'host
(es. target `git:https://gitea.emulab.it/...` o un target dedicato CI) (es. target `git:https://gitea.emulab.it/...` o un target dedicato CI)
- Passare PAT come parametro a `Invoke-RemoteBuild.ps1` (mai loggato/stampato) - Passare PAT a `Invoke-RemoteBuild.ps1` rispettando i vincoli sicurezza di §1.5
- [ ] Comportamento default invariato (`-UseGitClone` = `$false`) — nessuna regressione - [ ] Comportamento default invariato (`-UseGitClone` = `$false`) — nessuna regressione
- [ ] **Invoke-RemoteBuild.ps1 — modalità in-VM clone** - [ ] **Invoke-RemoteBuild.ps1 — modalità in-VM clone**
@@ -608,8 +761,8 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
C:\CI\build C:\CI\build
git -C C:\CI\build checkout $CloneCommit # pin al commit esatto git -C C:\CI\build checkout $CloneCommit # pin al commit esatto
``` ```
- [ ] Iniettare PAT come variabile d'ambiente della sessione WinRM (non argv, non log) - [ ] Implementare iniezione/pulizia PAT secondo §1.5 (env var sessione, no argv, no log,
- [ ] Pulire la variabile d'ambiente dopo il clone: `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')` cleanup `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`)
- [ ] **Test e2e con `-UseGitClone`** - [ ] **Test e2e con `-UseGitClone`**
- [ ] Aggiornare snapshot `BaseClean` con Git installato - [ ] Aggiornare snapshot `BaseClean` con Git installato
+938
View File
@@ -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
+195
View File
@@ -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 ^&gt; C:\Windows\Temp\post-install.log 2^&gt;^&amp;1</CommandLine>
<Description>Run post-install script from autounattend CD</Description>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>