From 96b65d40de5bee14cadcae5b7d76af6087859ae0 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 10 May 2026 01:01:43 +0200 Subject: [PATCH] feat(template): add Deploy-WinBuild2025.ps1 + autounattend; update TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TODO.md | 229 +++++-- template/Deploy-WinBuild2025.ps1 | 938 +++++++++++++++++++++++++++++ template/autounattend.template.xml | 195 ++++++ 3 files changed, 1324 insertions(+), 38 deletions(-) create mode 100644 template/Deploy-WinBuild2025.ps1 create mode 100644 template/autounattend.template.xml diff --git a/TODO.md b/TODO.md index bb72082..73a34cf 100644 --- a/TODO.md +++ b/TODO.md @@ -1,14 +1,13 @@ # TODO — Local CI/CD System - + > 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 -> per area e marcate **P0..P3**: +> con priorità e razionale. Le voci aperte sono raggruppate per area e marcate **P0..P3**: > > **Doc di setup operativi**: > - [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) > > - **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) - [x] Crea nuova VM in VMware Workstation con queste impostazioni: - 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 - 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 @@ -150,7 +149,7 @@ ## 1. Sicurezza & hardening ### 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 `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. - [ ] Regole firewall — limitare WinRM alla subnet build VM (`192.168.79.0/24` — VMnet8 NAT). -### 1.2 [P0] Restringere `TrustedHosts` lato host -File: probabile `Setup-Host.ps1` (e in vari run setup) — pattern `*` è incompatibile con altri carichi sull'host Windows 11. +### 1.2 [P0] Restringere `TrustedHosts` lato host (audit-only — già coperto) +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 Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate ``` ### 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 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). ### 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"): -- [ ] Recuperare il PAT da Credential Manager dell'host **subito prima** della WinRM session, mai prima. -- [ ] 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. +Requisiti di sicurezza per l'implementazione del clone in-VM. La sequenza operativa +(quando, dove, come) è dettagliata nella sezione "In-VM Git Clone (Ottimizzazione) — riferimento §3.3". + +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 -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`, -`DisableRealtimeMonitoring=1`, `LocalAccountTokenFilterPolicy=1`. +Stato attuale (post-refactor §7): +- **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 `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` 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 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 @@ -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. ### 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) 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`. ### 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. ### 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). 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`. - `PSUseShouldProcessForStateChangingFunctions`. - 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 `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\\` (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 `-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_` 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 @@ -574,28 +737,18 @@ VM avviata → WinRM: git clone --recurse-submodules https://@gitea/... ### Task -- [ ] **Setup-WinBuild2025.ps1 — installare Git for Windows** - - [ ] Scaricare e installare Git for Windows (installer silenzioso da git-scm.com) - ```powershell - $gitUrl = 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/Git-2.47.1-64-bit.exe' - Start-Process $gitUrl '/VERYSILENT /NORESTART /COMPONENTS=gitlfs' -Wait - ``` - - [ ] 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 +- [ ] **Tool prerequisiti nel template** — implementati in §6.6 (Tier-1 Toolchain): + - Git for Windows + 7-Zip + gh CLI installati con hash pinning e `Assert-Step` + - Rimuovere il commento "Git is NOT installed" dal docblock di Setup-WinBuild2025.ps1 + una volta applicato §6.6 + - curl.exe: già presente in Windows Server 2025 — verificare con `where.exe curl` - [ ] **Invoke-CIJob.ps1 — aggiungere switch `-UseGitClone`** - [ ] Quando `-UseGitClone` è attivo: - Saltare Phase 1 (host git clone) e la creazione del `HostSourceDir` temporaneo - Dopo Wait-VMReady, leggere PAT da Windows Credential Manager sull'host (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 - [ ] **Invoke-RemoteBuild.ps1 — modalità in-VM clone** @@ -608,8 +761,8 @@ VM avviata → WinRM: git clone --recurse-submodules https://@gitea/... C:\CI\build git -C C:\CI\build checkout $CloneCommit # pin al commit esatto ``` - - [ ] Iniettare PAT come variabile d'ambiente della sessione WinRM (non argv, non log) - - [ ] Pulire la variabile d'ambiente dopo il clone: `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')` + - [ ] Implementare iniezione/pulizia PAT secondo §1.5 (env var sessione, no argv, no log, + cleanup `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`) - [ ] **Test e2e con `-UseGitClone`** - [ ] Aggiornare snapshot `BaseClean` con Git installato diff --git a/template/Deploy-WinBuild2025.ps1 b/template/Deploy-WinBuild2025.ps1 new file mode 100644 index 0000000..05db9d4 --- /dev/null +++ b/template/Deploy-WinBuild2025.ps1 @@ -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:\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 diff --git a/template/autounattend.template.xml b/template/autounattend.template.xml new file mode 100644 index 0000000..ec1a3a0 --- /dev/null +++ b/template/autounattend.template.xml @@ -0,0 +1,195 @@ + + + + + + + + {{LOCALE_OS}} + + {{KEYBOARD}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_USER}} + + + + + + 0 + true + + + 1 + EFI + 100 + + + 2 + MSR + 16 + + + 3 + Primary + true + + + + + 1 + 1 + FAT32 + + S + + + 2 + 2 + + + 3 + 3 + NTFS + + C + + + + + + + + + + /IMAGE/INDEX + {{IMAGE_INDEX}} + + + + 0 + 3 + + + + + + true + CI + CI Lab + + {{PRODUCT_KEY}} + OnError + + + + false + + + false + Never + + + + + + + {{COMPUTER_NAME}} + {{TIMEZONE}} + CI + CI Lab + + + + + + {{KEYBOARD}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_USER}} + + + + + + {{ADMIN_PWD}} + 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>