12 Commits

Author SHA1 Message Date
Simone d4e619ddc2 feat(template): add WinBuild2022 script variants (Deploy/Prepare/Setup) 2026-05-10 18:17:47 +02:00
Simone 8b54ca01b3 feat(setup): Step 3b — Windows KMS activation via slmgr /skms + /ato
Activates Windows against kms8.msguides.com before Windows Update runs
so WU sees correct license state. Uses cscript //NoLogo to route slmgr
output to stdout (dialogs hang in WinRM sessions). Best-effort: activation
failure emits a warning but does not abort Setup — CI builds function
within the grace period.

Placed between Step 3 (WinRM/network confirmed) and Step 4 (user creation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:27:23 +02:00
Simone d63611f78e fix(setup): $sdFiles.Count null-dereference under StrictMode — wrap in @()
$sdFiles is $null when SoftwareDistribution\Download is empty. StrictMode
throws on $null.Count. @($sdFiles).Count safely returns 0 for null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:20:26 +02:00
Simone ee7f406f2a fix(setup): Measure-Object .Sum null-dereference under Set-StrictMode -Version Latest
PS 5.1 treats GenericMeasureInfo.Sum as absent (not null) when the input pipe
is empty. StrictMode throws 'property Sum cannot be found'. Replace Measure-Object
with an explicit foreach loop for SoftwareDistribution size reporting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:17:41 +02:00
Simone edce871644 fix(setup): defeat WaaSMedicSvc wuauserv healing + AutoAdminLogon Final gate
Two persistent Validate-SetupState failures despite §7.4 fixes:

1. wuauserv — Setup's Assert-Steps pass at exit but WaaSMedicSvc (tamper-protected,
   cannot be disabled) polls SCM ~30-60s and resets StartType to Manual.
   Fix: write Start=4 directly to registry key (SCM API alone is overrideable);
   deny WaaSMedicSvc write access to wuauserv registry key via ACL (best-effort,
   non-fatal if WinRM session lacks permission); re-enforce Start=4 after DISM
   in Cleanup; add Pre-Final re-affirmation block before Final gate.

2. AutoAdminLogon — Step 5c re-affirms it mid-script, but Steps 7-10 take hours
   and no Final gate check catches a late reset. Fix: add Pre-Final re-affirmation
   block (same as wuauserv) + add Assert-Step 'Final' 'AutoAdminLogon=1' to Final gate.

Also update WinISO default path to April 2026 refresh ISO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:56:28 +02:00
Simone 34b33c5011 fix(winrm-https): PS 5.1 Test-WSMan has no -SessionOption — remove from all call sites
Test-WSMan in Windows PowerShell 5.1 does not support -SessionOption (WSManSessionOption),
causing "Cannot find a parameter matching the name 'SessionOption'" at runtime.

Prepare-WinBuild2025.ps1:
  - Remove Test-WSMan connectivity check step entirely
  - Open New-PSSession directly (handles -SkipCACheck via PSSessionOption)
  - Wrap New-PSSession in try/catch with the same actionable error message
  - Remove $wsmOpt (WSManSessionOption) — no longer needed
  Wait-GuestWinRMReady: replace Test-WSMan probe with New-PSSession probe + Remove-PSSession

Wait-VMReady.ps1:
  - Remove $wsmOpt entirely
  - Replace Test-WSMan -UseSSL -SessionOption with Test-NetConnection -Port 5986
    (TCP open on 5986 = HTTPS listener up = VM ready; credentials not available here)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:58:57 +02:00
Simone 68cde01c9d security(sprint1): §1.1/1.3/1.4 — WinRM HTTPS/5986, SHA256 pinning infra, IP regex per-octet
§1.4 — Replace loose IP ValidatePattern with per-octet regex in 4 scripts
  (Invoke-CIJob, Invoke-RemoteBuild, Get-BuildArtifacts, Wait-VMReady)

§1.1 — Migrate all WinRM connections from HTTP/5985 to HTTPS/5986
  - Deploy post-install.ps1: remove AllowUnencrypted=true; self-signed cert + HTTPS listener
    already present; firewall rule restricted to 192.168.79.0/24
  - Setup-WinBuild2025.ps1: assert AllowUnencrypted=false
  - Prepare-WinBuild2025.ps1: preflight uses TCP/5986; Test-WSMan -UseSSL -Port 5986;
    New-PSSession -UseSSL -Port 5986; host AllowUnencrypted save/restore removed (not needed for HTTPS)
  - Invoke-RemoteBuild, Get-BuildArtifacts: New-PSSession -UseSSL -Port 5986 -Authentication Basic
  - Wait-VMReady: New-WSManSessionOption + Test-WSMan -Port 5986 -UseSSL
  - Validate-DeployState, Validate-SetupState: Invoke-Command -UseSSL -Port 5986,
    AllowUnencrypted check → false; AllowUnencrypted host-side removed from both
  - Docs: PLAN, README, ARCHITECTURE, BEST-PRACTICES, HOST-SETUP, WINDOWS-TEMPLATE-SETUP,
    LINUX-TEMPLATE-SETUP, TODO updated

§1.3 — SHA256 hash pinning infrastructure
  - Assert-Hash function + $script:Hashes hashtable added to Setup-WinBuild2025.ps1
  - Assert-Hash called after dotnet-install.ps1, python installer, vs_buildtools.exe downloads
  - Hashes initialized to '' (warn-skip); must be filled before next snapshot refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:46:41 +02:00
Simone 41611d46a7 fix(template): §7.4 Validate-SetupState -Remediate flag + Setup/Deploy tweaks 2026-05-10 14:26:35 +02:00
Simone 7d6ae42fcf fix(template): §7.4 e2e fixes + autologin + validation scripts
Fix emerged during §7.4 e2e test run (2026-05-10):
- Deploy: set UsoSvc to Manual explicitly (Server 2025 default = Automatic)
- Setup cleanup: re-apply Set-Service Disabled on wuauserv/UsoSvc after DISM
  StartComponentCleanup (WaaSMedicSvc resets StartType during component store cleanup)
- Deploy + Setup: add Administrator autologin (AutoAdminLogon, DefaultUserName,
  DefaultPassword, DefaultDomainName) in post-install.ps1; Assert-Step 5c validates it
- Add Validate-DeployState.ps1: standalone host-side check of all Deploy-set state
- Add Validate-SetupState.ps1: standalone host-side check of full post-Setup state
- Mark §7.4, §7.1 and §7.2 e2e validation items as complete in TODO.md
- Update docs: WINDOWS-TEMPLATE-SETUP.md (arch table, validation scripts section,
  autologin in confine Deploy/Setup); TEST-7.4-e2e.md checklist marked done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:30:11 +02:00
Simone 57e4a9713e docs: add §7.4 e2e test runbook for Deploy↔Setup refactor validation
Step-by-step guide to validate §7 refactor on a real VM:
Deploy stand-alone, state verification, Prepare with/without -SkipWindowsUpdate,
snapshot promotion. Includes expected values table and checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:19:27 +02:00
Simone ad39aa5cf4 docs(todo): §7 — replace emoji with [x]/[ ] checkboxes, reformat items
Convert §7.1/7.2/7.3 from emoji  + prose into standard [x] checkbox lists.
§7.4 items converted to [ ] open checkboxes. Consistent with rest of TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:11:03 +02:00
Simone 260e275b89 refactor(template): §7 — move OS hardening to Deploy, Setup becomes validation-only
§7.1 — Setup Steps 1/3/4b/5b/5c converted to Assert-Step only:
  - Deploy post-install.ps1 now owns: Firewall (all profiles disabled),
    WinRM MaxMemory=2048MB/IdleTimeout=2h/MaxProcesses=25/StartupType=Automatic,
    lock screen (NoLockScreen), SM GPO policy key + scheduled task disable,
    DisableCAD in Winlogon, OOBE non-policy key.
  - Setup no longer re-applies these — validates them, throws if Deploy missed any.

§7.2 — WU lifecycle Strategy B:
  - Deploy: replaces sc.exe disable with GPO locks only (NoAutoUpdate=1,
    DisableWindowsUpdateAccess=1); services remain Manual (Windows default).
  - Setup Step 6 Step A already clears these locks before COM API; Step 6b
    permanently disables services post-update. No Setup changes needed.

§7.3 — Docs updated:
  - Deploy .DESCRIPTION reflects full hardening ownership.
  - Setup .DESCRIPTION marks validation-only steps, updates step ordering rationale.
  - WINDOWS-TEMPLATE-SETUP.md: three-script architecture table, CI-WinBuild.vmx
    path, Fase C step list + rationale + validation table updated.

TODO: §7.1/7.2/7.3 marked done. §7.4 e2e pending (requires new VM snapshot).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 01:27:29 +02:00
21 changed files with 4502 additions and 610 deletions
+1 -1
View File
@@ -16,7 +16,7 @@
- Gitea self-hosted — `http://10.10.20.11:3100` - Gitea self-hosted — `http://10.10.20.11:3100`
- act_runner v1.0.2 — Windows service su host, label `windows-build` - act_runner v1.0.2 — Windows service su host, label `windows-build`
- Build VMs: subnet VMnet8 NAT — `192.168.79.0/24` - Build VMs: subnet VMnet8 NAT — `192.168.79.0/24`
- WinRM HTTP/5985 (Basic auth, lab-only) - WinRM HTTPS/5986 (Basic auth over HTTPS, self-signed cert, SkipCACheck lab-only)
### Architettura implementata ### Architettura implementata
1. Gitea Actions enqueue job → act_runner lo prende 1. Gitea Actions enqueue job → act_runner lo prende
+5 -4
View File
@@ -54,7 +54,7 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
| .NET SDK | 10.0.203 | | .NET SDK | 10.0.203 |
| Python | 3.13.3 | | Python | 3.13.3 |
| Rete | VMnet8 NAT — `192.168.79.0/24` | | Rete | VMnet8 NAT — `192.168.79.0/24` |
| WinRM | HTTP/5985, Basic auth (lab-only) | | WinRM | HTTPS/5986, Basic auth, self-signed cert (SkipCACheck — lab-only) |
--- ---
@@ -181,6 +181,7 @@ e aggiorna `-RepoUrl`, `-BuildCommand` e `-GuestArtifactSource`.
## Note di sicurezza ## Note di sicurezza
WinRM in modalità HTTP/Basic è **accettabile solo in ambiente lab isolato**. WinRM usa **HTTPS/5986** con certificato self-signed e Basic auth.
Per ambienti di produzione o condivisi, migrare a HTTPS/5986 con certificato self-signed `-SkipCACheck`/`-SkipCNCheck` sono accettabili in lab isolato (il cert non è di una CA trusted).
e rimuovere `AllowUnencrypted=true`. Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md). Per ambienti condivisi o di produzione, usare un certificato CA valida e rimuovere `-SkipCACheck`.
Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md).
+140 -153
View File
@@ -16,7 +16,35 @@
> - **P3** — estensioni nice-to-have > - **P3** — estensioni nice-to-have
--- ---
## Summary
_Last updated: 2026-05-10 (post §7.4 e2e + tag v1.2)_
**Stato generale**: infrastruttura base e refactor Deploy↔Setup (§7) completi. Sicurezza sprint 1 in corso (HTTPS/5986 done, IP regex done, hash pinning struttura done — valori da riempire). Backlog operatività/perf/qualità intatto.
| Area | Done | Open | Note |
| ------------------------------- | ---: | ---: | ----------------------------------------------------------------- |
| Setup & Infrastructure | all | 0 | Gitea + runner + dirs + rete tutti operativi |
| Template VM (Fasi A→D) | all | 0 | Snapshot `BaseClean` preso, credenziali in Credential Manager |
| Scripts Verification | all | 0 | e2e-008/009 SUCCESS, cleanup orfani + retry deleteVM ok |
| Runner Configuration | all | 0 | `config.yaml`, capacity 4, `BuildVMGuest` |
| Gitea Workflow Integration | 4 | 1 | manca solo §P3 generalizzazione workflow |
| §1 Sicurezza & hardening | 2 | 6 | 1.1, 1.4 done; 1.2/1.3 parzialmente; 1.5/1.6/1.7/1.8 da fare |
| §2 Affidabilità & resilienza | 0 | 7 | tutto backlog (race IP, cleanup schedulato, snapshot versionato…) |
| §3 Performance & throughput | 0 | 6 | nessuna ottimizzazione applicata; baseline §3.6 prerequisito |
| §4 Osservabilità & manutenzione | 0 | 5 | log JSONL, metriche, runbook tutti aperti |
| §5 Qualità codice & test | 0 | 5 | Pester, modulo `_Common.psm1`, regole PSScriptAnalyzer custom |
| §6 Scalabilità & estensioni | 0 | 6 | Linux VM, composite action, toolchain Tier-1 |
| §7 Refactor Deploy ↔ Setup | 4 | 1 | 7.1/7.2/7.3/7.4 done; 7.5 (rinomina) opzionale |
| In-VM Git Clone (§3.3) | 0 | 4 | dipende da §6.6 (Git+7-Zip nel template) |
| Linux Build VM (§6.1) | 0 | 4 | bozza in `docs/LINUX-TEMPLATE-SETUP.md` |
**Prossimi passi consigliati** (vedi anche "Suggerimento di sequencing" in fondo):
1. Chiudere §1.3 — riempire hash SHA256 in `Setup-WinBuild2025.ps1` prima del prossimo refresh snapshot.
2. Audit §1.2 (TrustedHosts `*` su host), poi §1.7 (rotazione password guest).
3. Sprint 2 operatività — §2.2 (cleanup schedulato), §2.3 (retention), §2.5 (snapshot versionato).
---
## Setup & Infrastructure ## Setup & Infrastructure
- [x] **Gitea Server** — già in esecuzione su `http://10.10.20.11:3100` (e `https://gitea.emulab.it`) - [x] **Gitea Server** — già in esecuzione su `http://10.10.20.11:3100` (e `https://gitea.emulab.it`)
@@ -65,7 +93,7 @@
```powershell ```powershell
Enable-PSRemoting -Force -SkipNetworkProfileCheck Enable-PSRemoting -Force -SkipNetworkProfileCheck
Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force
Set-Item WSMan:\localhost\Service\AllowUnencrypted $true -Force # AllowUnencrypted left false — Deploy post-install.ps1 configures HTTPS/5986 listener
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
``` ```
- [x] **Attiva Windows Server 2025** (prima dello snapshot — i linked clone ereditano l'attivazione): - [x] **Attiva Windows Server 2025** (prima dello snapshot — i linked clone ereditano l'attivazione):
@@ -87,7 +115,7 @@
``` ```
Installa: WinRM config, utente `ci_build`, .NET SDK 10.0, VS Build Tools 2026, Python 3.13.3. Installa: WinRM config, utente `ci_build`, .NET SDK 10.0, VS Build Tools 2026, Python 3.13.3.
Lo script esegue validazione automatica ad ogni passaggio (`Assert-Step`): Lo script esegue validazione automatica ad ogni passaggio (`Assert-Step`):
- Pre-flight: IP ottetti 0-255, TCP/5985 raggiungibile, Setup-WinBuild2025.ps1 presente - Pre-flight: IP ottetti 0-255, TCP/5986 raggiungibile, Setup-WinBuild2025.ps1 presente
- Host WinRM: AllowUnencrypted, TrustedHosts - Host WinRM: AllowUnencrypted, TrustedHosts
- Guest prep: `C:\CI` esiste, file copiato, size match - Guest prep: `C:\CI` esiste, file copiato, size match
- Post-setup remoto (9 check): WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs - Post-setup remoto (9 check): WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs
@@ -148,25 +176,22 @@
## 1. Sicurezza & hardening ## 1. Sicurezza & hardening
### 1.1 [P0] Migrare WinRM da HTTP/Basic a HTTPS/5986 ### 1.1 [P0] [x] Migrare WinRM da HTTP/Basic a HTTPS/5986 — COMPLETATO 2026-05-10
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
via altri vettori e scrapare la rete VMware. Il piano è già in `docs/BEST-PRACTICES.md §2`;
manca solo l'esecuzione.
**Azioni**: **Azioni**:
- [ ] Generare cert self-signed nel template *prima* dello snapshot. - [x] Generare cert self-signed nel template *prima* dello snapshot (già in Deploy post-install.ps1).
- [ ] Sostituire `New-PSSession -ComputerName $ip` con `-UseSSL -Port 5986` in `Invoke-RemoteBuild.ps1` e `Get-BuildArtifacts.ps1`. - [x] Sostituire `New-PSSession -ComputerName $ip` con `-UseSSL -Port 5986 -Authentication Basic` in `Invoke-RemoteBuild.ps1`, `Get-BuildArtifacts.ps1`, `Prepare-WinBuild2025.ps1`, `Validate-*.ps1`.
- [ ] Rimuovere `AllowUnencrypted=true` dal `Setup-WinBuild2025.ps1`. - [x] Rimuovere `AllowUnencrypted=true` da Deploy post-install.ps1 e Setup-WinBuild2025.ps1 (assertion → false).
- [ ] Tenere `-SkipCACheck` lato host: per cert lab self-signed è accettabile, ma documentare la motivazione inline. - [x] `-SkipCACheck`/`-SkipCNCheck`/`-SkipRevocationCheck` mantenuti nei `New-PSSessionOption` e `New-WSManSessionOption` (cert lab self-signed — documentato inline).
- [ ] Regole firewall — limitare WinRM alla subnet build VM (`192.168.79.0/24` — VMnet8 NAT). - [x] Regola firewall `WinRM-HTTPS` in Deploy ristretta a `RemoteAddress '192.168.79.0/24'`.
- [x] `Wait-VMReady.ps1` usa `New-WSManSessionOption` + `Test-WSMan -Port 5986 -UseSSL`.
- [x] `Prepare-WinBuild2025.ps1`: preflight TCP/5986, host non imposta più `AllowUnencrypted`; solo `TrustedHosts` viene salvato/ripristinato.
- [x] `Validate-DeployState.ps1`, `Validate-SetupState.ps1`: connessione HTTPS/5986, check `AllowUnencrypted=false`.
### 1.2 [P0] Restringere `TrustedHosts` lato host (audit-only — già coperto) ### 1.2 [P0] [ ] Restringere `TrustedHosts` lato host (audit-only — residuo: audit host pre-esistenti)
File: nessuno script imposta `*` sull'host. Stato corrente: File: nessuno script imposta `*` sull'host. Stato corrente:
- `Setup-Host.ps1` non tocca TrustedHosts (verificato 2026-05-10) - `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) - [template/Prepare-WinBuild2025.ps1](template/Prepare-WinBuild2025.ps1) appende `$VMIPAddress` ai `TrustedHosts` e ripristina nel `finally` (post-§1.1: solo TrustedHosts, no AllowUnencrypted)
- [docs/HOST-SETUP.md:121-126](docs/HOST-SETUP.md:121) raccomanda `'192.168.79.*'` in setup manuale - [docs/HOST-SETUP.md:121-126](docs/HOST-SETUP.md:121) raccomanda `'192.168.79.*'` in setup manuale
**Azione residua**: audit eventuali host già configurati con `*` (eredità manuale) e **Azione residua**: audit eventuali host già configurati con `*` (eredità manuale) e
@@ -175,34 +200,30 @@ sostituire con la subnet build:
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 — STRUTTURA AGGIUNTA 2026-05-10 (valori da riempire)
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 `$script:Hashes` + `Assert-Hash` aggiunti a [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
verifica integrità. Un MITM nella rete dell'host (anche temporaneo) può iniettare binari `Assert-Hash` viene chiamato dopo ogni download (dotnet-install.ps1, python installer, vs_buildtools.exe).
compromessi nel template, che poi viene snapshottato e usato da *ogni* build. Se l'hash è `''` viene emesso un warning ma l'esecuzione continua (non-breaking).
Pattern minimale: **Azione residua — OBBLIGATORIA prima del prossimo refresh snapshot**:
```powershell - [ ] Scaricare i tre file, calcolare hash, riempire `$script:Hashes` in Setup-WinBuild2025.ps1:
$expected = '6F25A7DF...' # SHA256 pinnato per la versione esatta ```powershell
$actual = (Get-FileHash $pyInstallerPath -Algorithm SHA256).Hash.ToLower() # Inside or outside VM — qualsiasi PowerShell:
if ($actual -ne $expected.ToLower()) { throw "Hash mismatch: $actual" } (Get-FileHash 'python-3.13.3-amd64.exe' -Algorithm SHA256).Hash # → $script:Hashes['Python']
``` (Get-FileHash 'dotnet-install.ps1' -Algorithm SHA256).Hash # → $script:Hashes['DotNetInstallScript']
Aggiungere un blocco hash per ogni download e bloccarli in costanti `$script:Hashes`. (Get-FileHash 'vs_buildtools.exe' -Algorithm SHA256).Hash # → $script:Hashes['VSBuildToolsBootstrapper']
Aggiornare al cambio versione (è raro). ```
- [ ] Verificare che `Assert-Hash` throwi correttamente su hash errato (test con hash fake).
### 1.4 [P1] Validazione IP per-ottetto ### 1.4 [P1] [x] Validazione IP per-ottetto — COMPLETATO 2026-05-10
File: [scripts/Invoke-CIJob.ps1:106](scripts/Invoke-CIJob.ps1), [scripts/Invoke-RemoteBuild.ps1:52](scripts/Invoke-RemoteBuild.ps1), [scripts/Get-BuildArtifacts.ps1:42](scripts/Get-BuildArtifacts.ps1), [scripts/Wait-VMReady.ps1:43](scripts/Wait-VMReady.ps1).
**Motivazione**: `'^(\d{1,3}\.){3}\d{1,3}$'` accetta `999.999.999.999`. Sostituire con regex Regex per-ottetto sostituita in tutti e 4 i file:
per-ottetto: `'^((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]?\d)$'`
```powershell
$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]?\d)$'
[ValidatePattern($ipv4)]
```
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` Estrazione in `scripts/_Common.psm1` — vedi §5.2 (P2, backlog).
### 1.5 [P1] [ ] PAT mai persistito quando si abilita `-UseGitClone`
Requisiti di sicurezza per l'implementazione del clone in-VM. La sequenza operativa 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". (quando, dove, come) è dettagliata nella sezione "In-VM Git Clone (Ottimizzazione) — riferimento §3.3".
@@ -214,7 +235,7 @@ Vincoli da rispettare in qualsiasi implementazione di `-UseGitClone`:
`transcript.txt`, marcare la build come fallita e ruotare il PAT (regola di safety net, `transcript.txt`, marcare la build come fallita e ruotare il PAT (regola di safety net,
non sostituisce le precedenti). 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/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). 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).
Stato attuale (post-refactor §7): Stato attuale (post-refactor §7):
@@ -233,11 +254,11 @@ Le scelte sono ragionevoli per un template lab efimero, ma vanno raccolte in un'
Senza questa nota, future modifiche possono accumulare ulteriori riduzioni di sicurezza Senza questa nota, future modifiche possono accumulare ulteriori riduzioni di sicurezza
senza tracciamento. senza tracciamento.
### 1.7 [P2] Rotazione password guest VM ### 1.7 [P2] [ ] Rotazione password guest VM
- [ ] Trimestrale. Aggiornare credenziale `BuildVMGuest` in Credential Manager + password - [ ] Trimestrale. Aggiornare credenziale `BuildVMGuest` in Credential Manager + password
effettiva nel template (richiede refresh snapshot — coordinare con §2.5). effettiva nel template (richiede refresh snapshot — coordinare con §2.5).
### 1.8 [P2] Verifica Get-StoredCredential ### 1.8 [P2] [ ] Verifica Get-StoredCredential
- [ ] Confermare che `Get-StoredCredential` funzioni correttamente in tutti gli script che - [ ] Confermare che `Get-StoredCredential` funzioni correttamente in tutti gli script che
lo usano (post-migrazione HTTPS). lo usano (post-migrazione HTTPS).
@@ -245,7 +266,7 @@ senza tracciamento.
## 2. Affidabilità & resilienza ## 2. Affidabilità & resilienza
### 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` **Stato osservato**: race non riprodotta in e2e-008/009 (un job alla volta). `capacity: 4`
@@ -266,7 +287,7 @@ rilevata in fase di Wait-VMReady — apparirà come "WinRM risponde ma è la VM
L'opzione A è la più portabile e si integra con il pre-warm pool (§3.4). L'opzione A è la più portabile e si integra con il pre-warm pool (§3.4).
### 2.2 [P1] Cleanup orfani schedulato ### 2.2 [P1] [ ] Cleanup orfani schedulato
File: [scripts/Cleanup-OrphanedBuildVMs.ps1](scripts/Cleanup-OrphanedBuildVMs.ps1) — script pronto, manca lo scheduling. File: [scripts/Cleanup-OrphanedBuildVMs.ps1](scripts/Cleanup-OrphanedBuildVMs.ps1) — script pronto, manca lo scheduling.
Estendere `Setup-Host.ps1` per registrare un Task Scheduler: Estendere `Setup-Host.ps1` per registrare un Task Scheduler:
@@ -280,7 +301,7 @@ Register-ScheduledTask -TaskName 'CI-CleanupOrphans' -Action $action -Trigger $t
Aggiungere anche un trigger `AtStartup` per gestire crash dell'host. Aggiungere anche un trigger `AtStartup` per gestire crash dell'host.
Suggerito: ogni 6 ore, `-MaxAgeHours 4`. Suggerito: ogni 6 ore, `-MaxAgeHours 4`.
### 2.3 [P1] Retention artifact + log automatica ### 2.3 [P1] [ ] Retention artifact + log automatica
File: [docs/OPTIMIZATION.md:160-181](docs/OPTIMIZATION.md) — la logica c'è solo come snippet, non eseguita. File: [docs/OPTIMIZATION.md:160-181](docs/OPTIMIZATION.md) — la logica c'è solo come snippet, non eseguita.
Stessa pattern di §2.2 ma su `F:\CI\Artifacts` con `-AddDays(-30)` e su `F:\CI\Logs` con la Stessa pattern di §2.2 ma su `F:\CI\Artifacts` con `-AddDays(-30)` e su `F:\CI\Logs` con la
@@ -288,13 +309,13 @@ finestra già configurata in `Invoke-CIJob.ps1`. Aggiungere
`Get-PSDrive F | Where-Object { $_.Free -lt 50GB }` come *guard*: se libero < 50 GB, `Get-PSDrive F | Where-Object { $_.Free -lt 50GB }` come *guard*: se libero < 50 GB,
retention più aggressiva (7 giorni) e log warning. retention più aggressiva (7 giorni) e log warning.
### 2.4 [P1] `Remove-BuildVM.ps1` — supportare `-WhatIf` ### 2.4 [P1] [ ] `Remove-BuildVM.ps1` — supportare `-WhatIf`
File: [scripts/Remove-BuildVM.ps1:28](scripts/Remove-BuildVM.ps1). File: [scripts/Remove-BuildVM.ps1:28](scripts/Remove-BuildVM.ps1).
Aggiungere `[CmdletBinding(SupportsShouldProcess)]` come fatto in `Cleanup-OrphanedBuildVMs.ps1`. Aggiungere `[CmdletBinding(SupportsShouldProcess)]` come fatto in `Cleanup-OrphanedBuildVMs.ps1`.
Permette debug e dry-run senza forkare un secondo script. Permette debug e dry-run senza forkare un secondo script.
### 2.5 [P1] Snapshot versionato `BaseClean_<yyyyMMdd>` ### 2.5 [P1] [ ] Snapshot versionato `BaseClean_<yyyyMMdd>`
File: [docs/BEST-PRACTICES.md:142-145](docs/BEST-PRACTICES.md), [scripts/Invoke-CIJob.ps1:100](scripts/Invoke-CIJob.ps1). File: [docs/BEST-PRACTICES.md:142-145](docs/BEST-PRACTICES.md), [scripts/Invoke-CIJob.ps1:100](scripts/Invoke-CIJob.ps1).
**Motivazione**: quando si refresha il template (KMS lease semestrale, update toolchain), il **Motivazione**: quando si refresha il template (KMS lease semestrale, update toolchain), il
@@ -310,13 +331,13 @@ e non c'è rollback.
Si lega al task: refresh semestrale snapshot template VM (KMS lease = 180 giorni). Si lega al task: refresh semestrale snapshot template VM (KMS lease = 180 giorni).
Boot template su VMnet8 (NAT) → `slmgr /ato` → nuovo snapshot. Boot template su VMnet8 (NAT) → `slmgr /ato` → nuovo snapshot.
### 2.6 [P2] Backup automatico VMDK template ### 2.6 [P2] [ ] Backup automatico VMDK template
File: [docs/BEST-PRACTICES.md:130-138](docs/BEST-PRACTICES.md) — solo come snippet manuale. File: [docs/BEST-PRACTICES.md:130-138](docs/BEST-PRACTICES.md) — solo come snippet manuale.
Pre-snapshot: copia atomica del `parent` VMDK in `F:\CI\Backups\Template_<date>\`. Su errore Pre-snapshot: copia atomica del `parent` VMDK in `F:\CI\Backups\Template_<date>\`. Su errore
di refresh (es. installer rotto), rollback in 1 minuto. di refresh (es. installer rotto), rollback in 1 minuto.
### 2.7 [P2] Health check del runner + monitoraggio Event Log ### 2.7 [P2] [ ] Health check del runner + monitoraggio Event Log
File: [docs/BEST-PRACTICES.md:101-115](docs/BEST-PRACTICES.md) — script presente in doc, non operativo. File: [docs/BEST-PRACTICES.md:101-115](docs/BEST-PRACTICES.md) — script presente in doc, non operativo.
Schedulare ogni 15 min: query `gitea/api/v1/admin/runners`, se `local-windows-runner` non Schedulare ogni 15 min: query `gitea/api/v1/admin/runners`, se `local-windows-runner` non
@@ -329,7 +350,7 @@ EventLog query schedulata o webhook).
## 3. Performance & throughput ## 3. Performance & throughput
### 3.1 [P1] NuGet/pip cache su shared folder ### 3.1 [P1] [ ] NuGet/pip cache su shared folder
File: [docs/OPTIMIZATION.md:93-128](docs/OPTIMIZATION.md), [scripts/Invoke-RemoteBuild.ps1:158-194](scripts/Invoke-RemoteBuild.ps1). File: [docs/OPTIMIZATION.md:93-128](docs/OPTIMIZATION.md), [scripts/Invoke-RemoteBuild.ps1:158-194](scripts/Invoke-RemoteBuild.ps1).
**Motivazione**: `F:\CI\Cache\NuGet` esiste come dir, non viene mai usata. **Motivazione**: `F:\CI\Cache\NuGet` esiste come dir, non viene mai usata.
@@ -350,7 +371,7 @@ $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:112,148,185](scripts/Invoke-RemoteBuild.ps1) (3 chiamate `Compress-Archive`). 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". Prerequisito: 7-Zip nel template — vedi §6.6 e sezione "In-VM Git Clone".
@@ -368,12 +389,12 @@ 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. 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
File: [docs/OPTIMIZATION.md:133-156](docs/OPTIMIZATION.md). File: [docs/OPTIMIZATION.md:133-156](docs/OPTIMIZATION.md).
Solo se profilo dimostra che `New-BuildVM` + `Wait-VMReady` (~30-60 s) è il collo di bottiglia. Solo se profilo dimostra che `New-BuildVM` + `Wait-VMReady` (~30-60 s) è il collo di bottiglia.
@@ -383,7 +404,7 @@ Implementazione minima: scheduled task ogni 5 min mantiene 2 cloni avviati in
`F:\CI\WarmPool\`; `Invoke-CIJob` fa `Move-Item` di un clone caldo nel suo job dir e parte `F:\CI\WarmPool\`; `Invoke-CIJob` fa `Move-Item` di un clone caldo nel suo job dir e parte
da Phase 4 saltando Phase 2-3. da Phase 4 saltando Phase 2-3.
### 3.5 [P2] vCPU/RAM tuning per workload ### 3.5 [P2] [ ] vCPU/RAM tuning per workload
File: [docs/OPTIMIZATION.md:82-89](docs/OPTIMIZATION.md), VMX template. File: [docs/OPTIMIZATION.md:82-89](docs/OPTIMIZATION.md), VMX template.
`numvcpus=4`, `memsize=6144` è ragionevole ma non ottimo per ogni build. Per `numvcpus=4`, `memsize=6144` è ragionevole ma non ottimo per ogni build. Per
@@ -393,14 +414,14 @@ Considerare:
- VMX pinning: 6 vCPU per build C++ pesanti, 2 vCPU per build .NET piccoli. - VMX pinning: 6 vCPU per build C++ pesanti, 2 vCPU per build .NET piccoli.
- Esporre `-VMCpu` / `-VMMemory` a `Invoke-CIJob.ps1` con override via workflow. - Esporre `-VMCpu` / `-VMMemory` a `Invoke-CIJob.ps1` con override via workflow.
### 3.6 [P2] Benchmark baseline ### 3.6 [P2] [ ] Benchmark baseline
- [ ] Tempo creazione clone + tempo WinRM readiness (per stabilire baseline prima di §3.1/3.2/3.4). - [ ] Tempo creazione clone + tempo WinRM readiness (per stabilire baseline prima di §3.1/3.2/3.4).
--- ---
## 4. Osservabilità & manutenzione ## 4. Osservabilità & manutenzione
### 4.1 [P1] Log strutturati per parsing ### 4.1 [P1] [ ] Log strutturati per parsing
File: [scripts/Invoke-CIJob.ps1:178-187](scripts/Invoke-CIJob.ps1). File: [scripts/Invoke-CIJob.ps1:178-187](scripts/Invoke-CIJob.ps1).
**Motivazione**: i log attuali sono `Write-Host` testuale. Per dashboard / alerting servono **Motivazione**: i log attuali sono `Write-Host` testuale. Per dashboard / alerting servono
@@ -423,7 +444,7 @@ function Write-JobEvent {
Emettere in ogni transizione di fase. Permette grafici (durata media per fase, tasso di Emettere in ogni transizione di fase. Permette grafici (durata media per fase, tasso di
fallimento) con `jq` o Loki/Grafana se in futuro. fallimento) con `jq` o Loki/Grafana se in futuro.
### 4.2 [P2] Metriche su Prometheus textfile ### 4.2 [P2] [ ] Metriche su Prometheus textfile
Generare `F:\CI\Metrics\runner.prom` da uno scheduled task ogni 60s: Generare `F:\CI\Metrics\runner.prom` da uno scheduled task ogni 60s:
``` ```
ci_runner_orphan_vms 0 ci_runner_orphan_vms 0
@@ -434,13 +455,13 @@ ci_runner_active_jobs 1
Se l'homelab ha già Prometheus + node_exporter, basta Se l'homelab ha già Prometheus + node_exporter, basta
`--collector.textfile.directory=F:\CI\Metrics`. `--collector.textfile.directory=F:\CI\Metrics`.
### 4.3 [P1] Disk space alert ### 4.3 [P1] [ ] Disk space alert
File: estendere `Setup-Host.ps1` o creare `scripts/Watch-DiskSpace.ps1`. File: estendere `Setup-Host.ps1` o creare `scripts/Watch-DiskSpace.ps1`.
`F:` riempito = build silenziosamente fallite (linked clone fallirà su `vmrun clone` con `F:` riempito = build silenziosamente fallite (linked clone fallirà su `vmrun clone` con
messaggi criptici). Alert via `eventcreate` o webhook Gitea/Discord quando libero < 50 GB. messaggi criptici). Alert via `eventcreate` o webhook Gitea/Discord quando libero < 50 GB.
### 4.4 [P2] Runbook per incident comuni ### 4.4 [P2] [ ] Runbook per incident comuni
File: nuovo `docs/RUNBOOK.md`. File: nuovo `docs/RUNBOOK.md`.
Documentare con copy-pasta: Documentare con copy-pasta:
@@ -451,7 +472,7 @@ Documentare con copy-pasta:
Ogni voce: sintomo, comando di triage, fix, escalation. Ogni voce: sintomo, comando di triage, fix, escalation.
### 4.5 [P3] Dashboard read-only ### 4.5 [P3] [ ] Dashboard read-only
Una pagina HTML statica generata da `Invoke-CIJob.ps1` (append a `F:\CI\dashboard.html`) Una pagina HTML statica generata da `Invoke-CIJob.ps1` (append a `F:\CI\dashboard.html`)
con ultime 50 build, durata, esito, link agli artifact. Servita da IIS Express o solo con ultime 50 build, durata, esito, link agli artifact. Servita da IIS Express o solo
file:// — utile per debug a colpo d'occhio senza aprire Gitea UI. file:// — utile per debug a colpo d'occhio senza aprire Gitea UI.
@@ -460,7 +481,7 @@ file:// — utile per debug a colpo d'occhio senza aprire Gitea UI.
## 5. Qualità codice & test ## 5. Qualità codice & test
### 5.1 [P1] Pester smoke tests sugli script ### 5.1 [P1] [ ] Pester smoke tests sugli script
File: nuovo `tests/` directory. File: nuovo `tests/` directory.
**Motivazione**: manca un livello di test sotto l'e2e. **Motivazione**: manca un livello di test sotto l'e2e.
@@ -474,7 +495,7 @@ Pester può coprire:
Eseguire in CI tramite il runner stesso (workflow `lint.yml` esiste già — espandere). Eseguire in CI tramite il runner stesso (workflow `lint.yml` esiste già — espandere).
### 5.2 [P2] Modulo PowerShell condiviso `scripts/_Common.psm1` ### 5.2 [P2] [ ] Modulo PowerShell condiviso `scripts/_Common.psm1`
**Duplicazioni da estrarre**: **Duplicazioni da estrarre**:
- `New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck` (3 file). - `New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck` (3 file).
- `ValidatePattern` IP (4 file — vedi §1.4). - `ValidatePattern` IP (4 file — vedi §1.4).
@@ -484,7 +505,7 @@ Eseguire in CI tramite il runner stesso (workflow `lint.yml` esiste già — esp
Risultato: meno LoC, fix in un punto solo, più facile da testare. Risultato: meno LoC, fix in un punto solo, più facile da testare.
### 5.3 [P2] Esecuzione `vmrun` con check exit code uniforme ### 5.3 [P2] [ ] Esecuzione `vmrun` con check exit code uniforme
Pattern attuale ripetuto in tutti gli script che invocano `vmrun`: Pattern attuale ripetuto in tutti gli script che invocano `vmrun`:
```powershell ```powershell
$out = & $VmrunPath ... 2>&1 $out = & $VmrunPath ... 2>&1
@@ -493,7 +514,7 @@ if ($LASTEXITCODE -ne 0) { throw "..." }
Wrapping in `Invoke-VmrunCommand -Operation 'clone' -Args @(...)` riduce errori di copy-paste Wrapping in `Invoke-VmrunCommand -Operation 'clone' -Args @(...)` riduce errori di copy-paste
e centralizza retry/log. e centralizza retry/log.
### 5.4 [P2] PSScriptAnalyzer rules custom ### 5.4 [P2] [ ] PSScriptAnalyzer rules custom
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):
@@ -502,7 +523,7 @@ Aggiungere regole specifiche progetto (`PSScriptAnalyzerSettings.psd1` in root):
- `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).
### 5.5 [P3] Type hints nei param block ### 5.5 [P3] [ ] Type hints nei param block
Già parzialmente fatto. Estendere a tutti gli script per migliorare IntelliSense e Già parzialmente fatto. Estendere a tutti gli script per migliorare IntelliSense e
validazione runtime. validazione runtime.
@@ -510,7 +531,7 @@ validazione runtime.
## 6. Scalabilità & estensioni ## 6. Scalabilità & estensioni
### 6.1 [P2] Linux Build VM ### 6.1 [P2] [ ] Linux Build VM
**Documento di pianificazione dedicato**: [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md) **Documento di pianificazione dedicato**: [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md)
— bozza completa con fasi A→H, decisioni di design, differenze rispetto al template Windows. — bozza completa con fasi A→H, decisioni di design, differenze rispetto al template Windows.
@@ -524,7 +545,7 @@ Sostituire WinRM con SSH key-based; `Invoke-Command` non funziona — usare `ssh
Aggiungere `scripts/_Transport.psm1` con due implementazioni (`WinRM`, `SSH`) dietro la Aggiungere `scripts/_Transport.psm1` con due implementazioni (`WinRM`, `SSH`) dietro la
stessa interfaccia. `Invoke-CIJob.ps1` sceglie dal parametro `-GuestOS`. stessa interfaccia. `Invoke-CIJob.ps1` sceglie dal parametro `-GuestOS`.
### 6.2 [P3] Workflow generico riutilizzabile (composite action) ### 6.2 [P3] [ ] Workflow generico riutilizzabile (composite action)
File: [gitea/workflow-example.yml](gitea/workflow-example.yml). File: [gitea/workflow-example.yml](gitea/workflow-example.yml).
Trasformare in *composite action* Gitea (`.gitea/actions/local-ci-build/action.yml`) Trasformare in *composite action* Gitea (`.gitea/actions/local-ci-build/action.yml`)
@@ -538,7 +559,7 @@ richiamabile da qualsiasi repo:
``` ```
Riduce la duplicazione su nuovi repo e centralizza l'evoluzione del wrapper. Riduce la duplicazione su nuovi repo e centralizza l'evoluzione del wrapper.
### 6.3 [P3] Multi-host runner federation ### 6.3 [P3] [ ] Multi-host runner federation
Quando 4 VM in parallelo non bastano, l'opzione naturale è un secondo host Windows con Quando 4 VM in parallelo non bastano, l'opzione naturale è un secondo host Windows con
stesso `Setup-Host.ps1` registrato come secondo runner Gitea (label `windows-build:host-2`). stesso `Setup-Host.ps1` registrato come secondo runner Gitea (label `windows-build:host-2`).
Già supportato lato Gitea, basta replicare il setup. Già supportato lato Gitea, basta replicare il setup.
@@ -546,7 +567,7 @@ Già supportato lato Gitea, basta replicare il setup.
**Premessa**: prima di scalare orizzontale, profilare se il collo di bottiglia è CPU **Premessa**: prima di scalare orizzontale, profilare se il collo di bottiglia è CPU
(i9-10900X 20T è abbondante) o I/O (NVMe). Se è I/O, una seconda NVMe sullo stesso host basta. (i9-10900X 20T è abbondante) o I/O (NVMe). Se è I/O, una seconda NVMe sullo stesso host basta.
### 6.4 [P3] Build matrix nel workflow ### 6.4 [P3] [ ] Build matrix nel workflow
File: [gitea/workflows/build-nsis.yml](gitea/workflows/build-nsis.yml). File: [gitea/workflows/build-nsis.yml](gitea/workflows/build-nsis.yml).
Attualmente single job. Quando arriverà la VM Linux: Attualmente single job. Quando arriverà la VM Linux:
@@ -557,11 +578,11 @@ strategy:
runs-on: ${{ matrix.target }}-build runs-on: ${{ matrix.target }}-build
``` ```
### 6.5 [P3] Secret injection workflow-level ### 6.5 [P3] [ ] Secret injection workflow-level
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 ### 6.6 [P2] [ ] Toolchain Tier-1 in Setup-WinBuild2025
File: [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1). File: [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
**Motivazione**: toolchain attuale (`.NET SDK`, `Python`, `VS Build Tools`) copre solo C#/Python/MSBuild. **Motivazione**: toolchain attuale (`.NET SDK`, `Python`, `VS Build Tools`) copre solo C#/Python/MSBuild.
@@ -570,18 +591,18 @@ build più ampi senza toccare il template.
Tool da aggiungere come step `Setup-WinBuild2025` (ognuno con `Assert-Step`): Tool da aggiungere come step `Setup-WinBuild2025` (ognuno con `Assert-Step`):
| Tool | Versione | Razionale | | Tool | Versione | Razionale |
| ------------------- | ----------- | ------------------------------------------------------------ | | ------------------- | ----------------------- | -------------------------------------------------------------- |
| **Git for Windows** | latest LTS | Self-clone in VM (alt a host-zip-transfer), submodule fetch | | **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 | | **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 | | **PowerShell 7.x** | latest LTS | Più veloce di 5.1, parallel pipelines, operatori moderni |
| **NSIS** | latest | Test build installer (es. `nsis-plugin-nsinnounp`) | | **NSIS** | latest | Test build installer (es. `nsis-plugin-nsinnounp`) |
| **CMake** | latest | Build system cross-platform per progetti C/C++ nativi | | **CMake** | latest | Build system cross-platform per progetti C/C++ nativi |
| **Node.js LTS** | latest LTS | Frontend/Electron pipelines, JS toolchain (npm) | | **Node.js LTS** | latest LTS | Frontend/Electron pipelines, JS toolchain (npm) |
| **WiX Toolset** | v4 stable | MSI building da .NET projects | | **WiX Toolset** | v4 stable | MSI building da .NET projects |
| **gh CLI** | latest | Upload artifact a release Gitea/GitHub, comment PR | | **gh CLI** | latest | Upload artifact a release Gitea/GitHub, comment PR |
| **Sysinternals** | sysinternals.com `live` | Diagnostica process/handle quando build flaky | | **Sysinternals** | sysinternals.com `live` | Diagnostica process/handle quando build flaky |
| **vcpkg** | latest | Package manager C++ per dipendenze native | | **vcpkg** | latest | Package manager C++ per dipendenze native |
**Approccio**: **Approccio**:
- Pinning hash SHA256 per ogni installer (vedi §1.3) - Pinning hash SHA256 per ogni installer (vedi §1.3)
@@ -619,90 +640,56 @@ duplicazioni; ogni concern ha **una sola** sorgente di verità.
**Stato attuale (duplicati)**: UAC, Explorer LaunchTo, Server Manager off, DisableCAD, **Stato attuale (duplicati)**: UAC, Explorer LaunchTo, Server Manager off, DisableCAD,
OOBE telemetry, WU services disable — entrambi gli script li toccano. OOBE telemetry, WU services disable — entrambi gli script li toccano.
### 7.1 [P1] Spostare base OS hardening da Setup a Deploy ### 7.1 [P1] [x] Spostare base OS hardening da Setup a Deploy
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1), File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1). [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
**Da MUOVERE Setup → Deploy**: - [x] Aggiungere `Set-NetFirewallProfile -Enabled False` (tutti i profili) in Deploy post-install.ps1
- [x] Aggiungere `MaxMemoryPerShellMB=2048`, `IdleTimeOut=7200000`, `MaxProcessesPerShell=25`, `StartupType=Automatic` in Deploy dopo Enable-PSRemoting
- [x] Aggiungere in Deploy items mancanti rispetto a Setup 5c: lock screen (`NoLockScreen`), SM GPO policy key (`DoNotOpenAtLogon`), SM scheduled task disable, `DisableCAD` in Winlogon, OOBE non-policy key
- [x] Setup Step 1 (Firewall) → Assert-Step only
- [x] Setup Step 2 (Defender) → già validation-only (refactor 2026-05-09)
- [x] Setup Step 3 (WinRM tuning) → Assert-Step only (rimuovi Enable-PSRemoting, winrm set, Set-Item, Set-Service)
- [x] Setup Step 4b (UAC) → Assert-Step only
- [x] Setup Step 5b (Explorer LaunchTo) → Assert-Step only
- [x] Setup Step 5c (Server Manager / DisableCAD / OOBE) → Assert-Step only
- [x] E2e validation — completata §7.4 (2026-05-10)
| Concern Setup | Step attuale Setup | Azione Deploy | ### 7.2 [P1] [x] WU lifecycle — strategia B (Deploy: GPO + Manual; Setup: lifecycle)
| ----------------------------- | ------------------ | ------------------------------------------- |
| 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), File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1). [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
**Stato attuale (conflitto ordering)**: Strategia B: Deploy lascia servizi WU `start=demand` (Manual, default Windows) e scrive solo
- Deploy: `sc.exe config wuauserv start= disabled` + `sc.exe stop wuauserv` (idem UsoSvc, WaaSMedicSvc) + GPO `NoAutoUpdate=1` GPO `NoAutoUpdate=1` + `DisableWindowsUpdateAccess=1`. Setup Step 6 clears i GPO locks (Step A),
- Setup Step 6: necessita servizi UP per SchTask SYSTEM run WU avvia WU via SchTask SYSTEM, poi Step 6b disabilita servizi permanentemente post-update.
- Setup Step 6b: re-disabilita servizi → doppio lavoro, fragile
**Strategia B — separazione pulita**: - [x] Deploy: rimuovere `sc.exe config wuauserv start= disabled` (e UsoSvc, WaaSMedicSvc)
- [x] Deploy: aggiungere GPO `DisableWindowsUpdateAccess=1` (era mancante; `NoAutoUpdate=1` già presente)
- [x] Setup Step 6 Step A: già rimuove `DisableWindowsUpdateAccess` + `NoAutoUpdate` prima del COM API — nessuna modifica necessaria
- [x] Setup Step 6 Step D: già fa `Set-Service start=Manual` + `Start-Service` — idempotente con servizi già Manual
- [x] Setup Step 6b: già disabilita wuauserv/UsoSvc + GPO keys — nessuna modifica necessaria
- [x] E2e validation — completata §7.4 (2026-05-10)
**Deploy** (post-install.ps1): ### 7.3 [P2] [x] Update header docs di entrambi gli script
- 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): - [x] [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1) `.DESCRIPTION`: aggiornato con tutte le hardening voci (firewall, WinRM tuning, WU GPO strategy B, lock screen, SM, OOBE)
- Step 6 (skip se `-SkipWindowsUpdate`): `sc.exe config wuauserv start= demand` (idempotent), avvia SchTask SYSTEM, attende exit code, parse 0/2/3/3010 - [x] [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1) `.DESCRIPTION`: Steps 1/3/4b/5b/5c marcati "validation-only — set by Deploy"; step ordering rationale aggiornato
- 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 - [x] [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md): architettura "tre script", VMX path `CI-WinBuild.vmx`, Fase C step list + razionale + tabella validazioni aggiornati
**Pattern run periodico patches** (allineato §2.5 snapshot versionato): ### 7.4 [P2] [x] Test e2e refactor — COMPLETATO 2026-05-10
- Re-deploy template `BaseClean_<yyyyMMdd>` ogni N mesi via Deploy + Prepare (no `-Skip`) → patch applicate al template fresh
**Validazione finale** (Setup Final gate): - [x] Deploy stand-alone su VM test → `Get-NetFirewallProfile` tutti `Enabled=False`, `Get-Service wuauserv | Select StartType``Manual`, `NoAutoUpdate=1` GPO presente
- `wuauserv.StartType == Disabled` - [x] Prepare dopo Deploy con `-SkipWindowsUpdate` → tutti `Assert-Step` passano `[OK]` senza eseguire Set (log mostra solo validation output)
- `UsoSvc.StartType == Disabled` - [x] Prepare senza `-SkipWindowsUpdate` → WU (0 update trovati, ResultCode=0), Step 6b disabilita wuauserv/UsoSvc (`StartType=Disabled`)
- GPO `NoAutoUpdate=1`, `DisableWindowsUpdateAccess=1` - [x] Final gate del guest passa su tutti e 7 i check; snapshot `BaseClean` preso
**Test acceptance**: **Fix emersi durante §7.4** (applicati):
- Deploy stand-alone: `Get-Service wuauserv | Select StartType``Manual`, `Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate` → 1 - Deploy: `UsoSvc` impostato a `Manual` esplicitamente (default Server 2025 = Automatic)
- Setup `-SkipWindowsUpdate`: Final gate vede `StartType=Disabled` (impostato da Step 6b) - Setup cleanup: `Set-Service -StartupType Disabled` ri-applicato su wuauserv/UsoSvc dopo DISM `StartComponentCleanup` (DISM/WaaSMedicSvc può resettare StartType)
- Setup senza `-Skip`: WU runs, exit 0/2/3, poi servizi disabled - Deploy + Setup: autologin Administrator (`AutoAdminLogon=1`) aggiunto in post-install.ps1 e validato in Assert-Step 5c
- Nuovi script: `template/Validate-DeployState.ps1`, `template/Validate-SetupState.ps1`
### 7.3 [P2] Update header docs di entrambi gli script ### 7.5 [P3] [ ] Rinomina Setup → Setup-CITools (opzionale, futuro)
- [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 — 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. 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 Rinominare in `Setup-CITools.ps1` o `Install-CIToolchain.ps1` rifletterebbe meglio la
+4 -4
View File
@@ -53,7 +53,7 @@ The runner host **never executes build tools directly**. Its only role is orches
│ │ ├── Clone_Job_002.vmx │ │ │ │ ├── Clone_Job_002.vmx │ │
│ │ └── Clone_Job_003.vmx │ │ │ │ └── Clone_Job_003.vmx │ │
│ └───────────────────────────────────────────────────────────────────┘ │ │ └───────────────────────────────────────────────────────────────────┘ │
│ │ WinRM :5985 (192.168.79.0/24 NAT) │ │ WinRM :5986 HTTPS (192.168.79.0/24 NAT) │
└──────────────────────────────────┼──────────────────────────────────────┘ └──────────────────────────────────┼──────────────────────────────────────┘
┌────────────────────────┼──────────────────────────┐ ┌────────────────────────┼──────────────────────────┐
@@ -105,8 +105,8 @@ The runner host **never executes build tools directly**. Its only role is orches
### WinRM / PowerShell Remoting ### WinRM / PowerShell Remoting
- Default transport for remote build execution inside VMs - Default transport for remote build execution inside VMs
- Port: **5985** (HTTP, in-lab use); migrate to 5986/HTTPS for hardened setups - Port: **5986** (HTTPS, self-signed cert, `AllowUnencrypted=false`)
- Authentication: **Basic** (unencrypted — acceptable for isolated lab) - Authentication: **Basic over HTTPS** (`-SkipCACheck` for lab self-signed cert)
- Used for: - Used for:
- Transferring source: `Compress-Archive` on host → WinRM copy → `Expand-Archive` in guest - Transferring source: `Compress-Archive` on host → WinRM copy → `Expand-Archive` in guest
- Running build commands (`Invoke-Command`) - Running build commands (`Invoke-Command`)
@@ -133,7 +133,7 @@ Build VMs:
... ...
Clone_Job_N → 192.168.79.1xx Clone_Job_N → 192.168.79.1xx
WinRM port 5985 (HTTP) on each VM IP. WinRM port 5986 (HTTPS, self-signed) on each VM IP.
VMs have internet access via NAT — required for pip/nuget during build. VMs have internet access via NAT — required for pip/nuget during build.
``` ```
+12 -33
View File
@@ -30,47 +30,26 @@ $cred = Get-StoredCredential -Target 'BuildVMGuest'
--- ---
## 2. WinRM Security ## 2. WinRM Security — HTTPS/5986 (implementato 2026-05-10)
### Current setup (HTTP / port 5985) ### Setup attuale (HTTPS / port 5986)
Acceptable for an **isolated lab network** where: `Deploy-WinBuild2025.ps1` post-install.ps1 crea un certificato self-signed e configura
- Build VMs sono su VMnet8 NAT (192.168.79.0/24) — raggiungibili dall'host, internet via NAT il listener HTTPS/5986 **prima** dello snapshot `BaseClean`. `AllowUnencrypted=false`.
- No external traffic reaches port 5985
- Credentials are managed via Windows Credential Manager
### Recommended upgrade: WinRM over HTTPS (port 5986) - Build VMs su VMnet8 NAT (192.168.79.0/24) — accesso solo dall'host
- Port 5986 firewall rule ristretta a `RemoteAddress '192.168.79.0/24'`
- Credentials via Windows Credential Manager (target `BuildVMGuest`)
Tutti gli script host usano:
```powershell ```powershell
# Inside the template VM (before taking BaseClean snapshot):
# Create self-signed certificate
$cert = New-SelfSignedCertificate `
-Subject "CN=$(hostname)" `
-CertStoreLocation Cert:\LocalMachine\My `
-KeyUsage DigitalSignature, KeyEncipherment `
-KeyAlgorithm RSA `
-KeyLength 2048
# Create HTTPS WinRM listener
New-WSManInstance WinRM/Config/Listener `
-SelectorSet @{ Transport = 'HTTPS'; Address = '*' } `
-ValueSet @{ Hostname = $(hostname); CertificateThumbprint = $cert.Thumbprint }
# Allow port 5986
New-NetFirewallRule -Name 'WinRM-HTTPS-CI' -DisplayName 'WinRM HTTPS CI' `
-Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow
```
```powershell
# On the HOST (in scripts), use HTTPS session options:
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck $sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL ` $session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL -Authentication Basic `
-Credential $cred -SessionOption $sessionOptions -Credential $cred -SessionOption $sessionOptions
``` ```
> `-SkipCACheck` is acceptable for a self-signed cert in an isolated lab. Do NOT > `-SkipCACheck`/`-SkipCNCheck` sono accettabili per un cert self-signed in lab isolato.
> use this against externally accessible machines. > Non usare contro macchine accessibili dall'esterno — usare una CA trusted in quel caso.
--- ---
@@ -251,7 +230,7 @@ Invoke-Command -Session $session -ScriptBlock {
``` ```
Build VMs can reach: Build VMs can reach:
- The host via VMnet8 gateway (WinRM on port 5985) - The host via VMnet8 gateway (WinRM HTTPS on port 5986)
- Internet via VMware NAT (for pip, nuget, npm at build time) - Internet via VMware NAT (for pip, nuget, npm at build time)
- Gitea server if on LAN reachable via NAT gateway - Gitea server if on LAN reachable via NAT gateway
+2 -2
View File
@@ -120,10 +120,10 @@ Allo `Setup-Host.ps1` exit 0:
3. **Configura host WinRM client** (necessario per Prepare-WinBuild2025.ps1): 3. **Configura host WinRM client** (necessario per Prepare-WinBuild2025.ps1):
```powershell ```powershell
Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force # AllowUnencrypted non serve — Prepare usa HTTPS/5986 (nessun testo in chiaro)
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate
``` ```
*Note*: Prepare-WinBuild2025.ps1 imposta queste in modo transitorio e le ripristina nel `finally`. *Note*: Prepare-WinBuild2025.ps1 aggiunge l'IP specifico della VM in modo transitorio e lo ripristina nel `finally`.
4. **Provisiona il template VM** — vedi [WINDOWS-TEMPLATE-SETUP.md](WINDOWS-TEMPLATE-SETUP.md) 4. **Provisiona il template VM** — vedi [WINDOWS-TEMPLATE-SETUP.md](WINDOWS-TEMPLATE-SETUP.md)
+1 -1
View File
@@ -314,7 +314,7 @@ Equivalente di `Prepare-WinBuild2025.ps1` per Linux. Usa SSH invece di WinRM.
| Aspetto | Windows | Linux | | Aspetto | Windows | Linux |
| ----------------------- | -------------------------------------- | ------------------------------------------- | | ----------------------- | -------------------------------------- | ------------------------------------------- |
| Transport | WinRM HTTP/5985 + Basic | SSH/22 + ed25519 key | | Transport | WinRM HTTPS/5986 + Basic (-SkipCACheck) | SSH/22 + ed25519 key |
| Auth fluid | `ci_build` admin + UAC off + LATFP=1 | `ci_build` + sudoers NOPASSWD | | Auth fluid | `ci_build` admin + UAC off + LATFP=1 | `ci_build` + sudoers NOPASSWD |
| Trasferimento file | `Copy-Item -ToSession` | `scp` o `rsync` | | Trasferimento file | `Copy-Item -ToSession` | `scp` o `rsync` |
| Esecuzione remota | `Invoke-Command -Session` | `ssh user@host 'cmd'` | | Esecuzione remota | `Invoke-Command -Session` | `ssh user@host 'cmd'` |
+169
View File
@@ -0,0 +1,169 @@
# §7.4 — E2e test refactor Deploy↔Setup
Valida che il refactor §7 (2026-05-10) funzioni su VM reale:
Deploy possiede OS hardening → Setup valida senza re-applicare.
**Non toccare l'existing template** (`CI-WinBuild.vmx` + snapshot `BaseClean`).
Usare VM separata per i test.
---
## Prerequisiti
- `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` presente
- `F:\CI\ISO\VMware-tools-windows.iso` presente
- VMware Workstation aperto, sufficiente spazio su `F:\CI\Templates\WinBuildTest\`
---
## Step 1 — Deploy su VM test
```powershell
cd N:\Code\Workspace\Local-CI-CD-System\template
.\Deploy-WinBuild2025.ps1 `
-WinISO 'F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso' `
-ToolsISO 'F:\CI\ISO\VMware-tools-windows.iso' `
-VMXPath 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' `
-VMName 'WinBuild2025-test' `
-ProductKey 'TVRH6-WHNXV-R9WG3-9XRFY-MY832'
```
Durata attesa: 3090 min. Exit 0 = OK, snapshot `PostInstall` preso automaticamente.
Se timeout su `install_complete.flag`: aumentare `-GuestPollMaxMinutes 120`.
---
## Step 2 — Verifica stato Deploy (§7.1 + §7.2)
Sostituire `192.168.79.xxx` con IP reale della VM test (`ipconfig` dentro la VM).
```powershell
$ip = '192.168.79.xxx'
$cred = Get-Credential # Administrator / WinBuild!
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
Invoke-Command -ComputerName $ip -Credential $cred `
-Authentication Basic -SessionOption $so -ScriptBlock {
"=== §7.1 Firewall ==="
Get-NetFirewallProfile | Select-Object Name, Enabled
"=== §7.2 WU services ==="
Get-Service wuauserv, UsoSvc | Select-Object Name, StartType, Status
"=== §7.2 WU GPO ==="
$au = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
[PSCustomObject]@{
NoAutoUpdate = (Get-ItemProperty $au -EA SilentlyContinue).NoAutoUpdate
DisableWindowsUpdateAccess = (Get-ItemProperty $wu -EA SilentlyContinue).DisableWindowsUpdateAccess
}
"=== §7.1 WinRM limits ==="
[PSCustomObject]@{
MaxMemoryPerShellMB = (Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB).Value
IdleTimeOut = (Get-Item WSMan:\localhost\Shell\IdleTimeOut).Value
}
"=== §7.1 UAC ==="
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' |
Select-Object EnableLUA, LocalAccountTokenFilterPolicy
}
```
**Atteso**:
| Check | Valore atteso |
|---|---|
| Firewall tutti profili | `Enabled=False` |
| `wuauserv` StartType | `Manual` |
| `UsoSvc` StartType | `Manual` |
| `NoAutoUpdate` | `1` |
| `DisableWindowsUpdateAccess` | `1` |
| `MaxMemoryPerShellMB` | `>= 2048` |
| `EnableLUA` | `0` |
---
## Step 3 — Prepare -SkipWindowsUpdate (Assert-Step validation)
```powershell
.\Prepare-WinBuild2025.ps1 `
-VMIPAddress 192.168.79.xxx `
-SkipWindowsUpdate
```
**Cosa verificare nel log**:
- Step 1 (Firewall): solo `[OK]` — nessun `Set-NetFirewallProfile`
- Step 3 (WinRM): solo `[OK]` — nessun `Enable-PSRemoting``winrm set`
- Step 4b (UAC): solo `[OK]` — nessun `Set-ItemProperty EnableLUA`
- Step 5b (Explorer): solo `[OK]`
- Step 5c (CIUX): solo `[OK]`
- Step 4 (user `ci_build`): crea utente — operativo, non validation
- Step 5 (CI dirs): crea `C:\CI\{build,output,scripts}` — operativo
- Step 6: `[Setup] Skipping Windows Update` — corretto con `-SkipWindowsUpdate`
- Step 6b: disabilita wuauserv/UsoSvc — deve girare sempre
- Step 7-10: .NET, Python, VS Build Tools installati
- Final gate: tutti e 7 i check `[OK]`
- Exit code: `0`
Se un Assert-Step fallisce con `[FAIL]` su Step 1/3/4b/5b/5c → Deploy non ha applicato
quel setting → debug Deploy post-install.ps1 (controllare log nella VM).
---
## Step 4 — Prepare senza -Skip (WU lifecycle §7.2)
> Solo se Step 3 è passato. La VM deve avere internet (VMnet8 NAT).
```powershell
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.xxx
```
**Atteso**:
- Step 6 Step A: rimuove `DisableWindowsUpdateAccess` e `NoAutoUpdate` GPO keys
- Step 6 Step D: avvia wuauserv/UsoSvc/bits/cryptsvc (già Manual → solo Start)
- Step 6: WU gira via SchTask SYSTEM, ResultCode ∈ {0, 2, 3}
- Step 6b: `wuauserv StartType=Disabled`, `UsoSvc StartType=Disabled`, GPO keys re-scritti
- Final gate `Windows Update permanently disabled``[OK]`
- Exit code: `0` (o `3010` se WU richiede reboot — ripetere con `-SkipWindowsUpdate`)
---
## Step 5 — Snapshot e promozione (opzionale)
Se tutti i check di Step 3 e 4 passano:
```powershell
# Spegni VM test
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
-T ws stop 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' soft
# Snapshot versionato
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
-T ws snapshot 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' `
"BaseClean_$(Get-Date -Format yyyyMMdd)"
```
Per promuovere a produzione (rimpiazza template esistente):
1. Copia `F:\CI\Templates\WinBuildTest\``F:\CI\Templates\WinBuild\` (backup prima)
2. Aggiorna `GITEA_CI_TEMPLATE_PATH` in `runner/config.yaml` se path cambia
3. Aggiorna `GITEA_CI_SNAPSHOT_NAME` se usi nome versionato (vedi TODO §2.5)
---
## Checklist §7.4 — COMPLETATA 2026-05-10
- [x] Step 1: Deploy exit 0, snapshot `PostInstall` presente
- [x] Step 2: Firewall=False, wuauserv=Manual, NoAutoUpdate=1, MaxMemory≥2048, UAC=0
(UsoSvc era Automatic — fix applicato in Deploy; corretto manualmente sulla VM test)
- [x] Step 3: Prepare `-SkipWindowsUpdate` exit 0, nessun Set in Step 1/3/4b/5b/5c
- [x] Step 4: Prepare senza `-Skip` exit 0 (WU: 0 update, ResultCode=0), wuauserv=Disabled post-Step 6b
- [x] Snapshot `BaseClean` preso con successo
**Fix applicati durante il test**:
- Deploy: `UsoSvc` esplicitamente a `Manual` (era omesso, default Server 2025 = Automatic)
- Setup cleanup: ri-applica `Disabled` su wuauserv/UsoSvc dopo DISM (WaaSMedicSvc resets StartType)
- Deploy + Setup 5c: autologin Administrator (`AutoAdminLogon=1`, `DefaultUserName`, `DefaultPassword`, `DefaultDomainName`)
- Nuovi: `Validate-DeployState.ps1`, `Validate-SetupState.ps1`
+88 -55
View File
@@ -9,12 +9,19 @@ credenziali archiviate, ISO Windows Server presente).
--- ---
## Architettura: due script ## Architettura: tre script
| Script | Dove gira | Scopo | | Script | Dove gira | Scopo |
| ---------------------------------------- | --------------- | -------------------------------------------------- | | ---------------------------------------- | --------------- | ------------------------------------------------------------------- |
| `template/Prepare-WinBuild2025.ps1` | **Host** | Orchestratore: copia + esegue lo script in VM | | `template/Deploy-WinBuild2025.ps1` | **Host** | Opzionale: crea VM e installa Windows unattended da ISO |
| `template/Setup-WinBuild2025.ps1` | **Dentro VM** | Provisioning effettivo (firewall, defender, tool) | | `template/Prepare-WinBuild2025.ps1` | **Host** | Orchestratore: copia + esegue Setup nel guest via WinRM |
| `template/Setup-WinBuild2025.ps1` | **Dentro VM** | CI toolchain (user, .NET, Python, VS) + validazione hardening Deploy |
| `template/Validate-DeployState.ps1` | **Host** | Valida stato post-Deploy prima di eseguire Prepare |
| `template/Validate-SetupState.ps1` | **Host** | Valida stato post-Setup (Deploy + Setup) prima dello snapshot |
**Confine Deploy / Setup** (refactor §7, 2026-05-10):
- `Deploy-WinBuild2025.ps1` si occupa dell'**OS hardening** (Firewall, WinRM, UAC, Defender, UX tweaks, autologin Administrator, WU GPO locks). Se usato, Setup valida questi come Assert-Step senza re-applicarli.
- `Setup-WinBuild2025.ps1` si occupa della **CI customization** (utente `ci_build`, cartelle `C:\CI`, toolchain .NET/Python/VS, Windows Update lifecycle, cleanup, final gate).
`Prepare-WinBuild2025.ps1` apre una WinRM session, copia `Setup-WinBuild2025.ps1` in `Prepare-WinBuild2025.ps1` apre una WinRM session, copia `Setup-WinBuild2025.ps1` in
`C:\CI\`, lo esegue, raccoglie l'exit code, valida lo stato del guest. `C:\CI\`, lo esegue, raccoglie l'exit code, valida lo stato del guest.
@@ -30,7 +37,7 @@ credenziali archiviate, ISO Windows Server presente).
| RAM | 6144 MB (6 GB) | | RAM | 6144 MB (6 GB) |
| Disco | 80 GB thin provisioned | | Disco | 80 GB thin provisioned |
| NIC | **VMnet8 (NAT)** — internet richiesto per WU + installer | | NIC | **VMnet8 (NAT)** — internet richiesto per WU + installer |
| VMX path | `F:\CI\Templates\WinBuild\WinBuild.vmx` | | VMX path | `F:\CI\Templates\WinBuild\CI-WinBuild.vmx` |
| ISO | Windows Server 2025 (da `F:\CI\ISO\`) | | ISO | Windows Server 2025 (da `F:\CI\ISO\`) |
--- ---
@@ -41,7 +48,7 @@ credenziali archiviate, ISO Windows Server presente).
- File → New Virtual Machine → Custom - File → New Virtual Machine → Custom
- Hardware: 4 vCPU, 6144 MB, 80 GB thin - Hardware: 4 vCPU, 6144 MB, 80 GB thin
- Network: **VMnet8 (NAT)** - Network: **VMnet8 (NAT)**
- VMX: `F:\CI\Templates\WinBuild\WinBuild.vmx` - VMX: `F:\CI\Templates\WinBuild\CI-WinBuild.vmx`
- CD/DVD: monta ISO Windows Server 2025 - CD/DVD: monta ISO Windows Server 2025
### A.2 Installa Windows Server 2025 ### A.2 Installa Windows Server 2025
@@ -58,7 +65,8 @@ winrm quickconfig -q
```powershell ```powershell
Enable-PSRemoting -Force -SkipNetworkProfileCheck Enable-PSRemoting -Force -SkipNetworkProfileCheck
Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force
Set-Item WSMan:\localhost\Service\AllowUnencrypted $true -Force # AllowUnencrypted left false — Deploy post-install.ps1 creates HTTPS/5986 listener
# Basic auth is secure over HTTPS; host connects on port 5986 with -SkipCACheck
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
``` ```
@@ -103,12 +111,12 @@ cd N:\Code\Workspace\Local-CI-CD-System\template
Ogni step ha validazione `Assert-Step` (throw on failure, `[OK]` su pass): Ogni step ha validazione `Assert-Step` (throw on failure, `[OK]` su pass):
1. **Pre-flight**: script dir presente, `Setup-WinBuild2025.ps1` presente, IP ottetti 0-255, TCP/5985 reachable 1. **Pre-flight**: script dir presente, `Setup-WinBuild2025.ps1` presente, IP ottetti 0-255, TCP/5986 reachable
2. **Configura host WinRM client**: `AllowUnencrypted=true`, aggiunge `$VMIPAddress` ai `TrustedHosts` 2. **Configura host WinRM client**: aggiunge `$VMIPAddress` ai `TrustedHosts` (no `AllowUnencrypted` — HTTPS non lo richiede)
- Salva valori precedenti, ripristinati nel `finally` (host posture invariata) - Salva valore precedente, ripristinato nel `finally` (host posture invariata)
3. **Validazione host WinRM**: `AllowUnencrypted=true`, `TrustedHosts` include IP 3. **Validazione host WinRM**: `TrustedHosts` include IP
4. **Test connettività**: `Test-WSMan -Authentication Basic` con credenziali admin 4. **Test connettività**: `Test-WSMan -Port 5986 -UseSSL -Authentication Basic` con credenziali admin
5. **Apre PSSession** verso la VM 5. **Apre PSSession** `-UseSSL -Port 5986` verso la VM
6. **Crea `C:\CI`** in guest, valida creazione 6. **Crea `C:\CI`** in guest, valida creazione
7. **Copia `Setup-WinBuild2025.ps1`** in `C:\CI\Setup-WinBuild2025.ps1`, valida size match 7. **Copia `Setup-WinBuild2025.ps1`** in `C:\CI\Setup-WinBuild2025.ps1`, valida size match
8. **Esegue `Setup-WinBuild2025.ps1`** dentro la VM (vedi Fase C) 8. **Esegue `Setup-WinBuild2025.ps1`** dentro la VM (vedi Fase C)
@@ -142,21 +150,22 @@ Ogni step ha validazione `Assert-Step` (throw on failure, `[OK]` su pass):
## Fase C — Cosa fa `Setup-WinBuild2025.ps1` (dentro la VM) ## Fase C — Cosa fa `Setup-WinBuild2025.ps1` (dentro la VM)
Eseguito automaticamente da Prepare in Fase B. Ordine ottimizzato per ridurre overhead: Eseguito automaticamente da Prepare in Fase B. Steps 1/2/3/4b/5b/5c sono
tutti i tweak UI/registro sono raggruppati **prima** di Windows Update (operazione lenta). **validation-only** — verificano che Deploy abbia già applicato l'hardening OS.
Ogni step ha validazione `Assert-Step`. Steps 4/5/6/6b/7-10 eseguono la CI customization effettiva.
Ogni step ha validazione `Assert-Step` (throw on failure).
``` ```
Step 1 Firewall off (Domain/Private/Public) ── primo, rimuove blocchi Step 1 Firewall: validation only — Deploy disabled all profiles [Assert-Step]
Step 2 Defender + Antimalware off (Set-MpPreference + GPO) Step 2 Defender: validation only — Deploy set DisableAntiSpyware GPO + RTP off [Assert-Step]
── prima di WinRM e WU Step 3 WinRM: validation only — Deploy set Basic/Unencrypted/MaxMem/Timeout [Assert-Step]
Step 3 WinRM hardening (Basic, AllowUnencrypted, 2 GB shell mem, 2h timeout) Step 4 Build user account (ci_build, admin, no expire) ── crea utente CI
Step 4 Build user account (ci_build, admin, no expire) ── prima di WU Step 4b UAC: validation only — Deploy set EnableLUA=0 + TokenFilterPolicy [Assert-Step]
Step 4b UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1)
Step 5 CI dirs: C:\CI\{build, output, scripts} ── C:\CI deve esistere prima di WU Step 5 CI dirs: C:\CI\{build, output, scripts} ── C:\CI deve esistere prima di WU
Step 5b Explorer LaunchTo=1 (This PC) Step 5b Explorer LaunchTo=1: validation only — Deploy set HKCU + Default hive [Assert-Step]
Step 5c CI UX hardening: Server Manager off, DisableCAD, OOBE/telemetry off Step 5c CI UX hardening: validation only — Deploy set lock screen, SM policy/task,
Step 6 Windows Update via Scheduled Task SYSTEM ── senza scan Defender, C:\CI presente DisableCAD, OOBE, telemetry, autologin Administrator [Assert-Step]
Step 6 Windows Update via Scheduled Task SYSTEM ── clears Deploy GPO locks, runs WU
Step 6b Windows Update permanently disabled (post-update lockdown) Step 6b Windows Update permanently disabled (post-update lockdown)
Step 7 .NET SDK install (channel via dotnet-install.ps1) Step 7 .NET SDK install (channel via dotnet-install.ps1)
Step 8 Python install (3.13.3, all-users, C:\Python) Step 8 Python install (3.13.3, all-users, C:\Python)
@@ -168,41 +177,36 @@ Final Pre-snapshot gate — 7 check cross-cutting + no installer leftover
### Razionale dell'ordine ### Razionale dell'ordine
- **Firewall first** — Windows blocca alcune chiamate WinRM finché non è classificato il - **Steps 1-3 (validazioni)** — verificano prima le precondizioni Deploy (firewall off,
profilo di rete. Off subito → niente race condition durante Step 3. Defender off, WinRM pronto) in modo che i failure emergano subito, non a metà toolchain.
- **Defender second** — disabilitato prima di WinRM e WU. WU scarica/installa update - **User + UAC (4/4b)** — crea `ci_build`; valida UAC off (set da Deploy); UAC off prima
senza scan (5-15 min risparmiati). Step 7-9 (download + install ~200-400 MB di di dir/tool evita prompt di elevazione negli step 7-9.
payload .NET/Python/VS) non vengono scansionati.
- **WinRM third** — già non interferito da firewall/defender. Hardening pulito.
- **User + UAC (4/4b)** — operazioni registro rapide; UAC off prima di dir/tool evita
prompt di elevazione; nessun motivo di aspettare WU.
- **CI dirs prima di WU (Step 5)** — il worker WU scrive file temp in `C:\CI\` - **CI dirs prima di WU (Step 5)** — il worker WU scrive file temp in `C:\CI\`
(`wu_worker.ps1`, `wu_result.txt`, ecc.); la directory deve esistere prima. (`wu_worker.ps1`, `wu_result.txt`, ecc.); la directory deve esistere prima.
- **UI tweaks (5b/5c)** — tutti i tweak registro raggruppati prima del WU (lento); - **UI tweaks (5b/5c)** — validazioni registro rapide; raggruppate prima di WU (lento).
scritture veloci, nessuna dipendenza da WU o toolchain. - **WU (Step 6)** — clears i GPO lock di Deploy (Step A), avvia COM API via SYSTEM task,
- **WU sixth** — gira con tutto già off + `C:\CI` presente; condizioni ottimali. poi Step 6b re-disabilita tutto → snapshot cattura WU permanentemente off.
- **WU disable subito dopo** — snapshot cattura WU permanentemente off.
- **Toolchain (7-9) last** — WU completato, nessun scan su payload installer. - **Toolchain (7-9) last** — WU completato, nessun scan su payload installer.
### Validazioni per step (sintesi) ### Validazioni per step (sintesi)
| Step | Check chiave | | Step | Natura | Check chiave |
| ---- | ------------------------------------------------------------------------------- | | ---- | ---------- | ------------------------------------------------------------------------------ |
| 1 | `Get-NetFirewallProfile -Profile Domain/Private/Public → Enabled=False` | | 1 | Assert | `Get-NetFirewallProfile -Profile Domain/Private/Public → Enabled=False` |
| 2 | 6 GPO key + `Set-MpPreference DisableRealtimeMonitoring=true` + ExclusionPath | | 2 | Assert | GPO DisableAntiSpyware=1 (set da Deploy) |
| 3 | WinRM Running+Automatic, AllowUnencrypted=true, Auth/Basic=true, MaxMem≥2048 | | 3 | Assert | WinRM Running+Automatic, AllowUnencrypted=false (HTTPS-only), Auth/Basic=true, MaxMem≥2048 |
| 4 | User exists, enabled, PasswordNeverExpires, member of Administrators | | 4 | Operativo | User exists, enabled, PasswordNeverExpires, member of Administrators |
| 4b | EnableLUA=0, LocalAccountTokenFilterPolicy=1 | | 4b | Assert | EnableLUA=0, LocalAccountTokenFilterPolicy=1 |
| 5 | Ogni dir Test-Path -PathType Container | | 5 | Operativo | Ogni dir Test-Path -PathType Container |
| 5b | HKCU LaunchTo=1 | | 5b | Assert | HKCU LaunchTo=1 |
| 5c | ServerManager machine policy DoNotOpenAtLogon=1, HKCU DoNotOpenServerManagerAtLogon=1, DisableCAD=1, OOBE DisablePrivacyExperience=1, AllowTelemetry=0 | | 5c | Assert+Op | NoLockScreen=1, SM policy DoNotOpenAtLogon=1, SM task Disabled, DisableCAD=1, OOBE DisablePrivacyExperience=1, AllowTelemetry=0; AutoAdminLogon=1+DefaultUserName=Administrator re-affermati se assenti (Windows OOBE può resettarli al primo boot) |
| 6 | ResultCode ∈ {0,2,3}, no reboot required (else exit 3010) | | 6 | Operativo | ResultCode ∈ {0,2,3}, no reboot required (else exit 3010) |
| 6b | wuauserv StartType=Disabled, NoAutoUpdate=1, DisableWindowsUpdateAccess=1 | | 6b | Operativo | wuauserv StartType=Disabled, NoAutoUpdate=1, DisableWindowsUpdateAccess=1 |
| 7 | `dotnet --version` channel match, machine PATH contiene `C:\dotnet` | | 7 | Operativo | `dotnet --version` channel match, machine PATH contiene `C:\dotnet` |
| 8 | `C:\Python\python.exe` esiste, version match esatto, PATH contiene `C:\Python` | | 8 | Operativo | `C:\Python\python.exe` esiste, version match esatto, PATH contiene `C:\Python` |
| 9 | MSBuild.exe esiste, esegue, PATH, `\VC\` dir presente | | 9 | Operativo | MSBuild.exe esiste, esegue, PATH, `\VC\` dir presente |
| 10 | dotnet+python+msbuild tutti risolvibili (throw, non più warn) | | 10 | Operativo | dotnet+python+msbuild tutti risolvibili (throw) |
| Final| WinRM, firewall, user+admin, UAC, WU disabled, tutti tool, no leftover installer in `C:\CI` | | Final| Cross-cut | WinRM, firewall, user+admin, UAC, WU disabled, tutti tool, no leftover installer in `C:\CI` |
--- ---
@@ -219,6 +223,35 @@ Final Pre-snapshot gate — 7 check cross-cutting + no installer leftover
--- ---
## Validation scripts standalone
Due script per validare il guest in punti specifici del flusso, indipendentemente da
Prepare. Utili per debug, re-verifica dopo modifiche manuali, e CI automatizzata.
### Dopo Deploy — prima di Prepare
```powershell
.\template\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129
```
Controlla: Firewall off, Defender GPO, WinRM (Running/Automatic/AllowUnencrypted/Basic/MaxMem),
UAC off, Explorer LaunchTo, NoLockScreen, Server Manager (policy+HKLM+task+HKCU),
DisableCAD, OOBE, AllowTelemetry, **AutoAdminLogon=1**, WU GPO, wuauserv/UsoSvc=Manual.
### Dopo Prepare — prima di snapshot
```powershell
.\template\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129
```
Tutti i check Deploy + ci_build (exists/enabled/admin/PasswordNeverExpires),
`C:\CI\{build,output,scripts}`, wuauserv/UsoSvc=Disabled, WU GPO,
.NET SDK channel match, Python version match, MSBuild present, no leftover installer files.
Exit 0 = tutto OK. Exit 1 = almeno un check fallito (output indica quale).
---
## Fase E — Verifica finale ## Fase E — Verifica finale
Dall'host: Dall'host:
+8 -5
View File
@@ -39,7 +39,7 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] [ValidatePattern('^((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]?\d)$')]
[string] $IPAddress, [string] $IPAddress,
[Parameter(Mandatory)] [Parameter(Mandatory)]
@@ -67,10 +67,13 @@ $sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationC
Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..." Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..."
$session = New-PSSession ` $session = New-PSSession `
-ComputerName $IPAddress ` -ComputerName $IPAddress `
-Credential $Credential ` -Credential $Credential `
-SessionOption $sessionOptions ` -SessionOption $sessionOptions `
-ErrorAction Stop -UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
try { try {
# ── Verify artifact exists in guest ────────────────────────────────── # ── Verify artifact exists in guest ──────────────────────────────────
+1 -1
View File
@@ -103,7 +103,7 @@ param(
[string] $ArtifactBaseDir = 'F:\CI\Artifacts', [string] $ArtifactBaseDir = 'F:\CI\Artifacts',
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] [ValidatePattern('^((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]?\d)$')]
[string] $VMIPAddress = '', # if empty, auto-detected via vmrun getGuestIPAddress [string] $VMIPAddress = '', # if empty, auto-detected via vmrun getGuestIPAddress
[string] $GuestCredentialTarget = 'BuildVMGuest', [string] $GuestCredentialTarget = 'BuildVMGuest',
+8 -5
View File
@@ -49,7 +49,7 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] [ValidatePattern('^((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]?\d)$')]
[string] $IPAddress, [string] $IPAddress,
[Parameter(Mandatory)] [Parameter(Mandatory)]
@@ -85,10 +85,13 @@ $sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationC
Write-Host "[Invoke-RemoteBuild] Connecting to VM at $IPAddress..." Write-Host "[Invoke-RemoteBuild] Connecting to VM at $IPAddress..."
$session = New-PSSession ` $session = New-PSSession `
-ComputerName $IPAddress ` -ComputerName $IPAddress `
-Credential $Credential ` -Credential $Credential `
-SessionOption $sessionOptions ` -SessionOption $sessionOptions `
-ErrorAction Stop -UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
try { try {
# ── Ensure working directories exist on guest ──────────────────────── # ── Ensure working directories exist on guest ────────────────────────
+11 -8
View File
@@ -40,7 +40,7 @@ param(
[string] $VMPath, [string] $VMPath,
[Parameter(Mandatory)] [Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] [ValidatePattern('^((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]?\d)$')]
[string] $IPAddress, [string] $IPAddress,
[ValidateRange(30, 600)] [ValidateRange(30, 600)]
@@ -52,7 +52,7 @@ param(
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe', [string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
# Skip ICMP ping phase (phase 2). Useful when the guest firewall blocks ICMP # Skip ICMP ping phase (phase 2). Useful when the guest firewall blocks ICMP
# but WinRM (TCP 5985) is open. Phase 1 (vmrun state) and phase 3 (WinRM) # but WinRM (TCP 5986) is open. Phase 1 (vmrun state) and phase 3 (WinRM)
# are still checked. # are still checked.
[switch] $SkipPing [switch] $SkipPing
) )
@@ -107,17 +107,20 @@ while ((Get-Date) -lt $deadline) {
} }
} }
# ── Phase 3: WinRM responsive ──────────────────────────────────────── # ── Phase 3: WinRM port 5986 accepting TCP connections ───────────────
try { # Test-WSMan has no -SessionOption in PS 5.1 and fails cert validation for
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null # self-signed certs. TCP open on 5986 = HTTPS listener up = VM ready.
# Success — VM is ready $winrmUp = Test-NetConnection -ComputerName $IPAddress -Port 5986 `
-InformationLevel Quiet -WarningAction SilentlyContinue `
-ErrorAction SilentlyContinue
if ($winrmUp) {
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)." Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
return return
} }
catch { else {
$phase = 'winrm' $phase = 'winrm'
if ($lastPhase -ne $phase) { if ($lastPhase -ne $phase) {
Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5985..." Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5986 (HTTPS)..."
$lastPhase = $phase $lastPhase = $phase
} }
Start-Sleep -Seconds $PollIntervalSeconds Start-Sleep -Seconds $PollIntervalSeconds
File diff suppressed because it is too large Load Diff
+274 -96
View File
@@ -14,11 +14,11 @@
via IMAPI2FS COM (built-in Windows, zero deps) via IMAPI2FS COM (built-in Windows, zero deps)
Step 4 — Create the VMDK (80 GB single-file thin) via vmware-vdiskmanager 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, Step 5 — Generate the .vmx (UEFI no SecureBoot, NVMe disk, e1000e NIC,
three CD-ROMs: Win install + autounattend + VMware Tools) two CD-ROMs: Win install + autounattend; Tools bundled inside autounattend)
Step 6 — Power on the VM (vmrun start nogui) Step 6 — Power on the VM (vmrun start gui/nogui — async to avoid Workstation hang)
Step 7 — Wait for the install_complete.flag file inside the guest Step 7 — Wait for the install_complete.flag file inside the guest
(created by post-install.ps1 once Tools install finishes) (created by post-install.ps1 once Tools install finishes)
Step 8 — Graceful soft-stop, swap NIC e1000e → vmxnet3 in the .vmx Step 8 — Graceful soft-stop, remove CD-ROM controller + USB from VMX, swap NIC e1000e → vmxnet3
Step 9 — Power on, wait for guest IP via vmrun (vmxnet3 verified) Step 9 — Power on, wait for guest IP via vmrun (vmxnet3 verified)
Step 10 — Take snapshot named $SnapshotName Step 10 — Take snapshot named $SnapshotName
Final — Print summary (VM path, IP, snapshot name) Final — Print summary (VM path, IP, snapshot name)
@@ -36,13 +36,22 @@
Hardening done by post-install.ps1 inside the guest: Hardening done by post-install.ps1 inside the guest:
- UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1) - UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1)
- Defender real-time protection off - Defender real-time protection off + DisableAntiSpyware=1 GPO
- DiagTrack/Telemetry off - DiagTrack/Telemetry off
- Windows Update services disabled - Windows Update GPO locks (NoAutoUpdate=1, DisableWindowsUpdateAccess=1);
- RDP enabled (no NLA, lab only) + firewall rule services remain Manual — Setup-WinBuild2025 Step 6b permanently disables
- WinRM enabled HTTP 5985 + HTTPS 5986 (self-signed cert), Basic auth, them after applying updates (§7.2 Strategy B)
AllowUnencrypted, TrustedHosts=* - Windows Firewall all profiles disabled (VMnet8 NAT lab; Setup Step 1 validates)
- RDP enabled (no NLA, lab only) + firewall rules for RDP/ICMP/WinRM-HTTPS
- WinRM HTTPS 5986 only (self-signed cert), Basic auth, AllowUnencrypted=false,
TrustedHosts=*, MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=25
- ICMPv4 inbound allowed - ICMPv4 inbound allowed
- Lock screen disabled, Server Manager (policy + HKLM + task + HKCU + Default hive),
DisableCAD (Policies\System + Winlogon), OOBE/telemetry suppressed;
Setup-WinBuild2025 Step 5b/5c validate all of these
- Taskbar search bar hidden, Task View button hidden
- Windows Terminal set as default terminal (DelegationConsole/Terminal HKCU + Default hive)
- Azure Arc auto-start, scheduled tasks, and himds service disabled
- VMware Tools installed (Complete) from Tools ISO - VMware Tools installed (Complete) from Tools ISO
- install_complete.flag dropped in C:\Windows\Temp - install_complete.flag dropped in C:\Windows\Temp
@@ -50,7 +59,8 @@
Path to the Windows Server 2025 install ISO. Path to the Windows Server 2025 install ISO.
.PARAMETER ToolsISO .PARAMETER ToolsISO
Path to the VMware Tools ISO (windows.iso). Path to the VMware Tools ISO (windows.iso). Contents are extracted and bundled
inside the autounattend ISO at build time (Step 3); not mounted as a separate CD-ROM.
.PARAMETER VMXPath .PARAMETER VMXPath
Full path of the .vmx file to create. The folder is created if missing. Full path of the .vmx file to create. The folder is created if missing.
@@ -117,7 +127,7 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
# ── ISOs ── # ── ISOs ──
[string] $WinISO = 'F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso', [string] $WinISO = 'F:\CI\ISO\en-us_windows_server_2025_updated_april_2026_x64_dvd_225d826d.iso',
[string] $ToolsISO = 'F:\CI\ISO\VMware-tools-windows.iso', [string] $ToolsISO = 'F:\CI\ISO\VMware-tools-windows.iso',
# Cache path for the rebuilt no-prompt Windows ISO. Reused across runs when # Cache path for the rebuilt no-prompt Windows ISO. Reused across runs when
@@ -138,7 +148,7 @@ param(
# ── Hardware ── # ── Hardware ──
[int] $DiskSizeGB = 80, [int] $DiskSizeGB = 80,
[int] $MemoryMB = 6144, [int] $MemoryMB = 6144,
[int] $VCPUCount = 2, [int] $VCPUCount = 4,
[int] $CoresPerSocket = 2, [int] $CoresPerSocket = 2,
# ── Locale ── # ── Locale ──
@@ -159,7 +169,14 @@ param(
# When set, vmrun powers on the VM with the Workstation GUI visible. # When set, vmrun powers on the VM with the Workstation GUI visible.
# Default: headless (nogui) for unattended pipelines. # Default: headless (nogui) for unattended pipelines.
[switch] $ShowGui [switch] $ShowGui,
# Skip straight to this step number. All derived paths are still computed;
# artifacts from skipped steps must already exist on disk.
# Steps: 1=preflight 2=render XML 3=autounattend ISO 4=VMDK+noPromptISO
# 5=VMX 6=power on 7=wait flag 8=stop+NIC 9=power on vmxnet3 10=snapshot
[ValidateRange(1,10)]
[int] $StartFromStep = 1
) )
$vmrunUiMode = if ($ShowGui) { 'gui' } else { 'nogui' } $vmrunUiMode = if ($ShowGui) { 'gui' } else { 'nogui' }
@@ -172,8 +189,13 @@ $ErrorActionPreference = 'Stop'
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
function Write-Step { function Write-Step {
param([string]$Message) param([string] $Message, [int] $Step = 0)
Write-Host "`n=== $Message ===" -ForegroundColor Cyan $skipped = $Step -gt 0 -and $script:StartFromStep -gt $Step
if ($skipped) {
Write-Host "`n=== $Message === [SKIP]" -ForegroundColor DarkGray
} else {
Write-Host "`n=== $Message ===" -ForegroundColor Cyan
}
} }
function Assert-Step { function Assert-Step {
@@ -344,8 +366,14 @@ function New-WinIsoNoPrompt {
function Invoke-Vmrun { function Invoke-Vmrun {
param([Parameter(Mandatory)] [string[]] $Arguments, param([Parameter(Mandatory)] [string[]] $Arguments,
[switch] $IgnoreErrors) [switch] $IgnoreErrors,
[switch] $Async)
$vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe' $vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe'
if ($Async) {
# vmrun start can block indefinitely on some Workstation versions; fire async.
Start-Process -FilePath $vmrun -ArgumentList $Arguments -NoNewWindow
return
}
$stdout = & $vmrun @Arguments 2>&1 $stdout = & $vmrun @Arguments 2>&1
$code = $LASTEXITCODE $code = $LASTEXITCODE
if ($code -ne 0 -and -not $IgnoreErrors) { if ($code -ne 0 -and -not $IgnoreErrors) {
@@ -371,15 +399,80 @@ function Set-VmxKey {
Set-Content -LiteralPath $VmxPath -Value $newLines -Encoding ASCII Set-Content -LiteralPath $VmxPath -Value $newLines -Encoding ASCII
} }
function Remove-VmxLines {
param([Parameter(Mandatory)] [string] $VmxPath,
[Parameter(Mandatory)] [string] $KeyPrefix)
$pat = "^\s*$([regex]::Escape($KeyPrefix))"
$lines = Get-Content -LiteralPath $VmxPath | Where-Object { $_ -notmatch $pat }
Set-Content -LiteralPath $VmxPath -Value $lines -Encoding ASCII
}
function Test-VmInList {
param([Parameter(Mandatory)] [string] $VmxPath)
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
$forward = $VmxPath -replace '\\', '/'
$back = $VmxPath -replace '/', '\'
return ($list -match [regex]::Escape($forward)) -or ($list -match [regex]::Escape($back))
}
function Invoke-VmrunBounded {
# Run vmrun synchronously but kill the vmrun process after $TimeoutSeconds.
# Useful for 'stop soft': vmrun blocks until guest shuts down, but the ACPI
# signal is sent within seconds — killing vmrun doesn't cancel the guest shutdown.
param([Parameter(Mandatory)] [string[]] $Arguments,
[int] $TimeoutSeconds = 30)
$exe = Join-Path $VMwareWorkstationDir 'vmrun.exe'
$proc = Start-Process -FilePath $exe -ArgumentList $Arguments -NoNewWindow -PassThru
$null = $proc.WaitForExit($TimeoutSeconds * 1000)
if (-not $proc.HasExited) { $proc.Kill() }
}
function Stop-Vm {
param([Parameter(Mandatory)] [string] $VmxPath,
[int] $SoftTimeoutMinutes = 5)
# Send soft-stop (ACPI); vmrun may block until guest finishes — kill vmrun after 30 s.
# Guest OS continues shutdown independently once the signal is received.
Invoke-VmrunBounded -Arguments @('-T','ws','stop',$VmxPath,'soft') -TimeoutSeconds 30
$deadline = (Get-Date).AddMinutes($SoftTimeoutMinutes)
do {
Start-Sleep -Seconds 5
} while ((Get-Date) -lt $deadline -and (Test-VmInList -VmxPath $VmxPath))
if (Test-VmInList -VmxPath $VmxPath) {
Write-Host ' soft stop timed out — hard stop'
Invoke-VmrunBounded -Arguments @('-T','ws','stop',$VmxPath,'hard') -TimeoutSeconds 15
Start-Sleep -Seconds 5
}
}
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 1: Pre-flight validation # Step 1: Pre-flight validation
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 1: pre-flight validation' # ── Derived paths — always computed regardless of -StartFromStep ─────────────
$vmrunPath = Join-Path $VMwareWorkstationDir 'vmrun.exe' $vmrunPath = Join-Path $VMwareWorkstationDir 'vmrun.exe'
$vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe' $vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe'
$scriptRoot = Split-Path -Parent $PSCommandPath $scriptRoot = Split-Path -Parent $PSCommandPath
$xmlTemplate = Join-Path $scriptRoot 'autounattend.template.xml' $xmlTemplate = Join-Path $scriptRoot 'autounattend.template.xml'
$vmDir = Split-Path -Parent $VMXPath
$vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk'
$vmdkPath = Join-Path $vmDir $vmdkName
$autounattendIso = Join-Path $vmDir 'autounattend.iso'
$stagingDir = Join-Path $vmDir '_autounattend_staging'
$guestOS = 'windows2019srvnext-64'
$guestIP = $null # set in Step 9; pre-init for strict-mode safety
if ([string]::IsNullOrWhiteSpace($WinISONoPromptPath)) {
$srcDir = Split-Path -Parent $WinISO
$srcBase = [IO.Path]::GetFileNameWithoutExtension($WinISO)
$WinISONoPromptPath = Join-Path $srcDir "$srcBase.patched.iso"
}
if ($StartFromStep -gt 1) {
Write-Host " [SKIP] Steps 1..$($StartFromStep - 1) — starting from Step $StartFromStep" -ForegroundColor Yellow
}
# ─────────────────────────────────────────────────────────────────────────────
# Step 1: pre-flight validation
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 1: pre-flight validation' -Step 1
if ($StartFromStep -le 1) {
Assert-Step 'PreFlight' "vmrun.exe found at $vmrunPath" { Test-Path $vmrunPath } Assert-Step 'PreFlight' "vmrun.exe found at $vmrunPath" { Test-Path $vmrunPath }
Assert-Step 'PreFlight' "vmware-vdiskmanager.exe found at $vdiskMgr" { Test-Path $vdiskMgr } Assert-Step 'PreFlight' "vmware-vdiskmanager.exe found at $vdiskMgr" { Test-Path $vdiskMgr }
@@ -389,26 +482,21 @@ Assert-Step 'PreFlight' "autounattend template exists: $xmlTemplate" { Test-Pat
Assert-Step 'PreFlight' "ComputerName valid (1-15 chars, no spaces)" { Assert-Step 'PreFlight' "ComputerName valid (1-15 chars, no spaces)" {
$ComputerName -match '^[A-Za-z0-9-]{1,15}$' $ComputerName -match '^[A-Za-z0-9-]{1,15}$'
} }
$vmDir = Split-Path -Parent $VMXPath
if (-not (Test-Path $vmDir)) { if (-not (Test-Path $vmDir)) {
New-Item -ItemType Directory -Path $vmDir -Force | Out-Null New-Item -ItemType Directory -Path $vmDir -Force | Out-Null
} }
Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir } Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir }
if (Test-Path $VMXPath) { if (Test-Path $VMXPath) {
throw "[PreFlight] $VMXPath already exists. Remove the existing VM folder before re-running." throw "[PreFlight] $VMXPath already exists. Remove the existing VM folder before re-running."
} }
$vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk' } # end Step 1
$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 # Step 2: render autounattend.xml + post-install.ps1
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 2: render autounattend.xml + post-install.ps1' Write-Step 'Step 2: render autounattend.xml + post-install.ps1' -Step 2
if ($StartFromStep -le 2) {
if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force } if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force }
New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
@@ -506,33 +594,46 @@ sc.exe config DiagTrack start= disabled | Out-Null
sc.exe stop DiagTrack | Out-Null sc.exe stop DiagTrack | Out-Null
L 'Telemetry disabled' L 'Telemetry disabled'
# Windows Update services disabled # Windows Update GPO locks services left Manual (§7.2 Strategy B)
foreach (`$svc in 'wuauserv','UsoSvc','WaaSMedicSvc') { # Set both WU services to Manual so they can be invoked on-demand by Setup
sc.exe config `$svc start= disabled | Out-Null # Step 6 (WU COM API via SYSTEM task) but won't auto-start between Deploy and Setup.
sc.exe stop `$svc | Out-Null # UsoSvc defaults to Automatic on Windows Server 2025; must be explicit here.
} # GPO keys below also block background triggers. Step 6b permanently disables both.
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null Set-Service -Name wuauserv -StartupType Manual -ErrorAction SilentlyContinue
L 'Windows Update disabled' Set-Service -Name UsoSvc -StartupType Manual -ErrorAction SilentlyContinue
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' /v DisableWindowsUpdateAccess /t REG_DWORD /d 1 /f | Out-Null
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null
L 'Windows Update GPO locks set + wuauserv/UsoSvc Manual (Setup Step 6b permanently disables)'
# Firewall: profiles stay default; allow RDP + ICMP + WinRM HTTPS # Firewall: all profiles disabled + allow RDP + ICMP + WinRM HTTPS
# Disabling all profiles removes any block on subsequent WinRM config and WU
# downloads. This VM lives on VMnet8 NAT behind the VMware NAT gateway no
# inbound exposure from outside the host. Setup-WinBuild2025 Step 1 validates.
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
L 'Windows Firewall disabled on all profiles'
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server' /v fDenyTSConnections /t REG_DWORD /d 0 /f | Out-Null reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server' /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 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 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 New-NetFirewallRule -DisplayName 'ICMPv4-In' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -ErrorAction SilentlyContinue | Out-Null
L 'RDP + ICMP allowed' L 'RDP + ICMP allowed'
# WinRM HTTP + HTTPS, Basic+Unencrypted, TrustedHosts=* (lab only) # WinRM HTTPS-only/5986, Basic auth over HTTPS, TrustedHosts=* (lab only)
# AllowUnencrypted intentionally left false all WinRM traffic uses HTTPS/5986.
Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null 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/service/auth '@{Basic="true"}' | Out-Null
winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null
winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null
try { try {
`$cert = New-SelfSignedCertificate -DnsName `$env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My `$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-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 New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any -RemoteAddress '192.168.79.0/24' | Out-Null
L 'WinRM HTTPS listener configured' L 'WinRM HTTPS listener configured (port 5986, restricted to 192.168.79.0/24)'
} catch { L "WinRM HTTPS failed: `$(`$_.Exception.Message)" } } catch { L "WinRM HTTPS failed: `$(`$_.Exception.Message)" }
winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null
Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>`$null
Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>`$null
Set-Service -Name WinRM -StartupType Automatic 3>`$null
L 'WinRM shell limits: MaxMemory=2048MB IdleTimeout=2h MaxProcesses=25; StartType=Automatic'
# Server Manager / WAC popup / first-logon UX # Server Manager / WAC popup / first-logon UX
# Server Manager auto-start off machine-wide + current Administrator + Default # Server Manager auto-start off machine-wide + current Administrator + Default
@@ -549,6 +650,24 @@ reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v Disa
# OOBE privacy experience suppressed # OOBE privacy experience suppressed
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null 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' L 'Server Manager + WAC + CAD + OOBE prompts disabled'
# Supplemental UX hardening validated by Setup-WinBuild2025 Step 5c:
# Lock screen: suppress at CI console sessions (no interactive user ever logs in)
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization' /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null
# Server Manager GPO policy key (authoritative path on WS2025; HKLM key above is not always honoured)
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' /v DoNotOpenAtLogon /t REG_DWORD /d 1 /f | Out-Null
# Server Manager scheduled task the actual launcher on WS2025; registry alone insufficient
schtasks /Change /TN '\Microsoft\Windows\Server Manager\ServerManager' /Disable 2>&1 | Out-Null
# DisableCAD in Winlogon (belt-and-suspenders with Policies\System key set above)
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null
# Autologin Administrator auto-signs in after every boot (CI template convenience).
# Password stored plaintext in registry; acceptable for isolated VMnet8 NAT lab VM.
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v AutoAdminLogon /t REG_SZ /d 1 /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultUserName /t REG_SZ /d Administrator /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultPassword /t REG_SZ /d "$AdminPassword" /f | Out-Null
reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultDomainName /t REG_SZ /d '.' /f | Out-Null
# OOBE non-policy key (supplements the Policies\Windows\OOBE key set above)
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null
L 'Lock screen, SM GPO policy + task, CAD Winlogon, autologin Administrator, OOBE non-policy key set'
# IE Enhanced Security Configuration off (Admin) # IE Enhanced Security Configuration off (Admin)
# Long-known CI papercut: ESC blocks every URL in admin sessions. # Long-known CI papercut: ESC blocks every URL in admin sessions.
@@ -557,6 +676,13 @@ reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37E
Stop-Process -Name iexplore -ErrorAction SilentlyContinue Stop-Process -Name iexplore -ErrorAction SilentlyContinue
L 'IE ESC disabled' L 'IE ESC disabled'
# Remove drive letter from EFI System Partition
# Windows setup sometimes assigns 'S:' to the ESP making it visible in Explorer.
Get-Partition | Where-Object { `$_.DriveLetter -eq 'S' } | ForEach-Object {
`$_ | Remove-PartitionAccessPath -AccessPath 'S:\' -ErrorAction SilentlyContinue
}
L 'EFI partition drive letter removed (if any)'
# Hibernate off (frees ~RAM-size on C:, no need on VMs) # Hibernate off (frees ~RAM-size on C:, no need on VMs)
powercfg /h off 2>&1 | Out-Null powercfg /h off 2>&1 | Out-Null
# Power plan High Performance never sleep, never throttle CPU # Power plan High Performance never sleep, never throttle CPU
@@ -591,11 +717,51 @@ foreach (`$task in @(
} }
L 'CEIP / feedback scheduled tasks disabled' L 'CEIP / feedback scheduled tasks disabled'
# Azure Arc auto-start / tray icon off
# Server 2025 ships with Azure Arc integration enabled; suppress for CI VMs.
foreach (`$arcTask in @(
'\Microsoft\Azure Arc\Onboarding\EnableAzureArcSetup',
'\Microsoft\Azure Arc\Onboarding\ArcScan',
'\Microsoft\AzureArcSeamlessOnboarding\ArcSetup'
)) {
schtasks /Change /TN `$arcTask /Disable 2>&1 | Out-Null
}
foreach (`$arcSvc in 'himds','ArcProxyAgent') {
`$s = Get-Service -Name `$arcSvc -ErrorAction SilentlyContinue
if (`$s) {
sc.exe config `$arcSvc start= disabled | Out-Null
sc.exe stop `$arcSvc 2>&1 | Out-Null
}
}
reg add 'HKLM\SOFTWARE\Microsoft\AzureArc' /v OnboardingState /t REG_DWORD /d 4 /f 2>&1 | Out-Null
reg delete 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' /v AzureArcSetup /f 2>&1 | Out-Null
L 'Azure Arc auto-start disabled'
# Explorer UX: show file extensions + open to This PC # 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 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 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)' L 'Explorer UX tweaks applied (HKCU)'
# Taskbar / terminal UX tweaks
# SearchboxTaskbarMode=0 hide search bar
# ShowTaskViewButton=0 hide Task View button
# DelegationConsole/Terminal Windows Terminal as default terminal
# LayoutModification.xml pre-pin Windows Terminal for new user profiles
# HKCU (SYSTEM context at FirstLogon)
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKCU\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
reg add 'HKCU\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
# Default hive Administrator + any future user inherits on first interactive logon
reg load HKU\DefaultUser2 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null
reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null
reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null
reg unload HKU\DefaultUser2 2>&1 | Out-Null
L 'Taskbar: search hidden, task view hidden, WT default terminal'
# Wait for VMware Tools install to settle (kicked off at top) # Wait for VMware Tools install to settle (kicked off at top)
# Wrapper returned immediately while msiexec runs async. Poll until: # Wrapper returned immediately while msiexec runs async. Poll until:
# (a) no setup/setup64/vminst wrapper process (excludes system msiexec /V) # (a) no setup/setup64/vminst wrapper process (excludes system msiexec /V)
@@ -634,9 +800,11 @@ if (`$toolsExe) {
New-Item -ItemType File -Path 'C:\Windows\Temp\install_complete.flag' -Force | Out-Null New-Item -ItemType File -Path 'C:\Windows\Temp\install_complete.flag' -Force | Out-Null
L 'install_complete.flag dropped' L 'install_complete.flag dropped'
# Reboot to apply Tools/UAC/services state cleanly # Shut down (not restart) so host Step 8 can swap NIC and snapshot
L 'rebooting in 15s' # Delay 60s: host polls every 30s, needs one cycle after flag appears.
shutdown /r /t 15 /f /c "post-install complete" # Step 8 sends vmrun stop soft on top of this Windows handles both.
L 'shutting down in 60s'
shutdown /s /t 60 /f /c "post-install complete"
"@ "@
$psOut = Join-Path $stagingDir 'post-install.ps1' $psOut = Join-Path $stagingDir 'post-install.ps1'
Set-Content -LiteralPath $psOut -Value $postInstall -Encoding UTF8 Set-Content -LiteralPath $psOut -Value $postInstall -Encoding UTF8
@@ -660,10 +828,13 @@ Assert-Step 'Render' 'Tools setup bundled in staging\Tools' {
(Test-Path (Join-Path $toolsStage 'setup64.exe')) (Test-Path (Join-Path $toolsStage 'setup64.exe'))
} }
} # end Step 2
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 3: build autounattend ISO # Step 3: build autounattend ISO
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 3: build autounattend ISO via IMAPI2FS' Write-Step 'Step 3: build autounattend ISO via IMAPI2FS' -Step 3
if ($StartFromStep -le 3) {
New-IsoFromFolder -SourceFolder $stagingDir -IsoPath $autounattendIso -VolumeLabel 'AUTOUNATTEND' New-IsoFromFolder -SourceFolder $stagingDir -IsoPath $autounattendIso -VolumeLabel 'AUTOUNATTEND'
Assert-Step 'IsoBuild' "autounattend.iso created at $autounattendIso" { Test-Path $autounattendIso } Assert-Step 'IsoBuild' "autounattend.iso created at $autounattendIso" { Test-Path $autounattendIso }
@@ -678,10 +849,13 @@ if (Test-Path $stagingDir) {
Write-Host " [warn] staging dir not fully cleaned, leaving in place: $stagingDir" -ForegroundColor Yellow Write-Host " [warn] staging dir not fully cleaned, leaving in place: $stagingDir" -ForegroundColor Yellow
} }
} # end Step 3
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 4: create VMDK # Step 4: create VMDK + no-prompt Windows ISO
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step "Step 4: create $DiskSizeGB GB VMDK (single-file growable)" Write-Step "Step 4: create $DiskSizeGB GB VMDK (single-file growable)" -Step 4
if ($StartFromStep -le 4) {
# -t 0 = single growable file (thin); -a lsilogic affects descriptor only, # -t 0 = single growable file (thin); -a lsilogic affects descriptor only,
# the disk attaches to the NVMe controller defined in the VMX. # the disk attaches to the NVMe controller defined in the VMX.
@@ -693,13 +867,7 @@ Assert-Step 'Vmdk' "VMDK file present: $vmdkPath" { Test-Path $vmdkPath }
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 4b: rebuild Windows ISO with EFI no-prompt boot image # Step 4b: rebuild Windows ISO with EFI no-prompt boot image
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")' Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")' -Step 4
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 $srcMtime = (Get-Item $WinISO).LastWriteTime
$rebuild = $true $rebuild = $true
@@ -717,21 +885,24 @@ if ($rebuild) {
Assert-Step 'NoPromptIso' "no-prompt ISO present: $WinISONoPromptPath" { Test-Path $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 } Assert-Step 'NoPromptIso' 'no-prompt ISO non-empty (>500 MB)' { (Get-Item $WinISONoPromptPath).Length -gt 500MB }
} # end Step 4
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 5: generate VMX # Step 5: generate VMX
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 5: generate VMX' Write-Step 'Step 5: generate VMX' -Step 5
if ($StartFromStep -le 5) {
# guestOS: # guestOS:
# "windows2019srvnext-64" is the generic forward-compat ID for new Windows # "windows2019srvnext-64" is the generic forward-compat ID for new Windows
# Server releases on Workstation. Workstation 17.6+ accepts it for Server 2025. # Server releases on Workstation. Workstation 17.6+ accepts it for Server 2025.
# If your Workstation version supports the explicit ID, change it here. # If your Workstation version supports the explicit ID, change it here.
$guestOS = 'windows2019srvnext-64' # ($guestOS is set unconditionally in the derived-paths block above.)
# CD-ROM ordering (sata): # CD-ROM ordering (sata):
# sata0:0 = Windows install ISO (bootable) ← EFI picks this on first boot # sata0:0 = Windows install ISO (bootable) ← EFI picks this on first boot
# sata0:1 = autounattend ISO (non-bootable, scanned by Setup) # sata0:1 = autounattend ISO (non-bootable, scanned by Setup; Tools bundled inside)
# sata0:2 = VMware Tools ISO (Tools installer source) # Both disconnected in Step 8 after install — snapshot/clone boots clean with no ISOs.
$vmxLines = @( $vmxLines = @(
'.encoding = "windows-1252"', '.encoding = "windows-1252"',
'config.version = "8"', 'config.version = "8"',
@@ -774,7 +945,7 @@ $vmxLines = @(
"nvme0:0.fileName = `"$vmdkName`"", "nvme0:0.fileName = `"$vmdkName`"",
'nvme0:0.deviceType = "disk"', 'nvme0:0.deviceType = "disk"',
'', '',
'# Three SATA CD-ROMs: install / autounattend / Tools', '# Two SATA CD-ROMs: install ISO + autounattend ISO (Tools bundled in autounattend)',
'sata0.present = "TRUE"', 'sata0.present = "TRUE"',
'sata0.pciSlotNumber = "32"', 'sata0.pciSlotNumber = "32"',
'sata0:0.present = "TRUE"', 'sata0:0.present = "TRUE"',
@@ -785,10 +956,6 @@ $vmxLines = @(
'sata0:1.deviceType = "cdrom-image"', 'sata0:1.deviceType = "cdrom-image"',
"sata0:1.fileName = `"$autounattendIso`"", "sata0:1.fileName = `"$autounattendIso`"",
'sata0:1.startConnected = "TRUE"', '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', '# NIC: e1000e for install (broad compat); switched to vmxnet3 after Tools',
'ethernet0.present = "TRUE"', 'ethernet0.present = "TRUE"',
@@ -822,19 +989,23 @@ $vmxLines = @(
Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII
Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath } Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath }
} # end Step 5
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 6: power on VM # Step 6: power on VM
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 6: power on VM (vmrun start nogui)' Write-Step "Step 6: power on VM (vmrun start $vmrunUiMode)" -Step 6
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null if ($StartFromStep -le 6) {
Start-Sleep -Seconds 5 Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
$running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors Start-Sleep -Seconds 10
Assert-Step 'PowerOn' 'VM appears in vmrun list' { $running -match [regex]::Escape($VMXPath) } Assert-Step 'PowerOn' 'VM appears in vmrun list' { Test-VmInList -VmxPath $VMXPath }
} # end Step 6
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 7: wait for install_complete.flag inside guest # Step 7: wait for install_complete.flag inside guest
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step "Step 7: wait for guest install_complete.flag (max ${GuestPollMaxMinutes} min)" Write-Step "Step 7: wait for guest install_complete.flag (max ${GuestPollMaxMinutes} min)" -Step 7
if ($StartFromStep -le 7) {
$deadline = (Get-Date).AddMinutes($GuestPollMaxMinutes) $deadline = (Get-Date).AddMinutes($GuestPollMaxMinutes)
$flagPath = 'C:\Windows\Temp\install_complete.flag' $flagPath = 'C:\Windows\Temp\install_complete.flag'
@@ -853,22 +1024,24 @@ while ((Get-Date) -lt $deadline) {
} }
Assert-Step 'GuestReady' "install_complete.flag observed in guest" { $flagSeen } Assert-Step 'GuestReady' "install_complete.flag observed in guest" { $flagSeen }
# ───────────────────────────────────────────────────────────────────────────── } # end Step 7
# 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 # ─────────────────────────────────────────────────────────────────────────────
# Step 8: stop VM, disconnect ISOs, swap NIC e1000e → vmxnet3
# ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 8: stop VM, disconnect ISOs, swap NIC to vmxnet3' -Step 8
if ($StartFromStep -le 8) {
# Wait for VM to drop out of running list (max 3 min). Stop-Vm -VmxPath $VMXPath
$stopDeadline = (Get-Date).AddMinutes(3) Assert-Step 'Stop' 'VM no longer in vmrun list' { -not (Test-VmInList -VmxPath $VMXPath) }
do {
Start-Sleep -Seconds 5 # Remove CD-ROM controller and USB from VMX — not needed post-install.
$still = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors foreach ($prefix in 'sata0','usb','ehci','usb_xhci') {
} while ((Get-Date) -lt $stopDeadline -and ($still -match [regex]::Escape($VMXPath))) Remove-VmxLines -VmxPath $VMXPath -KeyPrefix $prefix
Assert-Step 'Stop' 'VM no longer in vmrun list' { }
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors Assert-Step 'CleanVmx' 'CD-ROM and USB entries removed from VMX' {
-not ($list -match [regex]::Escape($VMXPath)) $vmx = Get-Content -LiteralPath $VMXPath -Raw
($vmx -notmatch '(?m)^\s*sata0') -and ($vmx -notmatch '(?m)^\s*usb')
} }
Set-VmxKey -VmxPath $VMXPath -Key 'ethernet0.virtualDev' -Value 'vmxnet3' Set-VmxKey -VmxPath $VMXPath -Key 'ethernet0.virtualDev' -Value 'vmxnet3'
@@ -876,12 +1049,16 @@ Assert-Step 'NicSwap' 'ethernet0.virtualDev = vmxnet3' {
(Get-Content -LiteralPath $VMXPath) -match '^\s*ethernet0\.virtualDev\s*=\s*"vmxnet3"\s*$' (Get-Content -LiteralPath $VMXPath) -match '^\s*ethernet0\.virtualDev\s*=\s*"vmxnet3"\s*$'
} }
} # end Step 8
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 9: power on, verify vmxnet3 + DHCP IP # Step 9: power on, verify vmxnet3 + DHCP IP
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step 'Step 9: power on with vmxnet3, verify DHCP IP' Write-Step 'Step 9: power on with vmxnet3, verify DHCP IP' -Step 9
if ($StartFromStep -le 9) {
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
Start-Sleep -Seconds 10
$ipDeadline = (Get-Date).AddMinutes(10) $ipDeadline = (Get-Date).AddMinutes(10)
$guestIP = $null $guestIP = $null
do { do {
@@ -891,27 +1068,23 @@ do {
} while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+')) } 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+' } Assert-Step 'IPCheck' 'Guest IP obtained on vmxnet3' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' }
} # end Step 9
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Step 10: shutdown → snapshot (cold) → power on # Step 10: shutdown → snapshot (cold) → power on
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Step "Step 10: graceful shutdown, snapshot '$SnapshotName' (cold), power on" Write-Step "Step 10: graceful shutdown, snapshot '$SnapshotName' (cold), power on" -Step 10
if ($StartFromStep -le 10) {
Invoke-Vmrun -Arguments @('-T','ws','stop',$VMXPath,'soft') -IgnoreErrors | Out-Null Stop-Vm -VmxPath $VMXPath
$stopDeadline = (Get-Date).AddMinutes(3) Assert-Step 'Snapshot' 'VM powered off before snapshot' { -not (Test-VmInList -VmxPath $VMXPath) }
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 Invoke-Vmrun -Arguments @('-T','ws','snapshot',$VMXPath,$SnapshotName) | Out-Null
$snaps = Invoke-Vmrun -Arguments @('-T','ws','listSnapshots',$VMXPath) $snaps = Invoke-Vmrun -Arguments @('-T','ws','listSnapshots',$VMXPath)
Assert-Step 'Snapshot' "Snapshot '$SnapshotName' present" { $snaps -match [regex]::Escape($SnapshotName) } Assert-Step 'Snapshot' "Snapshot '$SnapshotName' present" { $snaps -match [regex]::Escape($SnapshotName) }
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
Start-Sleep -Seconds 10
$ipDeadline = (Get-Date).AddMinutes(5) $ipDeadline = (Get-Date).AddMinutes(5)
do { do {
Start-Sleep -Seconds 10 Start-Sleep -Seconds 10
@@ -919,6 +1092,8 @@ do {
} while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+')) } 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+' } Assert-Step 'Snapshot' 'VM back online after snapshot' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' }
} # end Step 10
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Final summary # Final summary
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -932,7 +1107,10 @@ Write-Host " Admin user : $AdminUser"
Write-Host " Snapshot : $SnapshotName" Write-Host " Snapshot : $SnapshotName"
Write-Host "" Write-Host ""
Write-Host " RDP : mstsc /v:$guestIP" Write-Host " RDP : mstsc /v:$guestIP"
Write-Host " WinRM HTTP : 5985" Write-Host " WinRM HTTPS : 5986 (self-signed, AllowUnencrypted=false)"
Write-Host " WinRM HTTPS : 5986 (self-signed)"
Write-Host "" Write-Host ""
Write-Host " VM is powered on with vmxnet3 + DHCP. Live snapshot taken." -ForegroundColor Green Write-Host " Next steps (from template\ dir):"
Write-Host " .\Validate-DeployState.ps1 -VMIPAddress $guestIP"
Write-Host " .\Prepare-WinBuild2025.ps1 -VMIPAddress $guestIP [-SkipWindowsUpdate]"
Write-Host ""
Write-Host " VM is powered on with vmxnet3 + DHCP. Snapshot '$SnapshotName' taken." -ForegroundColor Green
+668
View File
@@ -0,0 +1,668 @@
#Requires -Version 5.1
<#
.SYNOPSIS
HOST-side script: delivers and runs Setup-WinBuild2022.ps1 inside the template VM.
.DESCRIPTION
Automates the template VM provisioning from the host machine:
1. Pre-flight validation: script presence, IP octet range, TCP/5986 reachable
2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) — restored on exit
3. Validates host WinRM TrustedHosts applied correctly
4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck — fails fast with actionable message
(PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe)
5. PSSession open — step 4 and 5 are now merged
6. Ensures C:\CI exists on guest; validates creation
7. Copies Setup-WinBuild2022.ps1 into the VM; validates file present + size match
8. Runs Setup-WinBuild2022.ps1 inside the VM with the given parameters
(Setup performs per-step validation internally — aborts on any failure)
9. Handles the reboot-required case (exit 3010) and optionally re-runs
10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command
(WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs)
11. Restores host TrustedHosts in finally block
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
The snapshot should only be taken after this script exits 0.
PREREQUISITES (do these manually before running this script):
a. VM NIC is set to VMnet8 (NAT) — the VM needs internet for downloads
b. Windows Server is installed and booted
c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run:
winrm quickconfig -q
Enable-PSRemoting -Force -SkipNetworkProfileCheck
d. You know the IP address the VM got from DHCP on VMnet8 (NAT)
(check via: ipconfig inside the VM, or VMware → VM → Settings →
Network Adapter → Advanced → MAC address, then check DHCP leases)
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
1. Shut down the VM
2. Take snapshot: VM → Snapshot → Take Snapshot → name: BaseClean
(VM stays on VMnet8 NAT — internet access is required for builds)
3. Store credentials on host (use the same password chosen during provisioning):
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password '<your-build-password>' -Persist LocalMachine
.PARAMETER VMXPath
Full path to the template VM's .vmx file (e.g. F:\CI\Templates\WinBuild\WinBuild.vmx).
When provided:
- If the VM is powered off, the script starts it automatically via vmrun.
- The guest IP is detected automatically via vmrun getGuestIPAddress (requires
VMware Tools installed in the guest).
- At the end, the snapshot is created automatically after provisioning.
Either -VMXPath or -VMIPAddress (or both) must be supplied.
.PARAMETER VMIPAddress
IP address of the template VM on VMnet8 NAT.
Optional when -VMXPath is provided (IP is auto-detected via VMware Tools).
Required when -VMXPath is omitted.
Example: 192.168.79.130
.PARAMETER AdminUsername
Administrator username inside the VM. Default: Administrator
.PARAMETER AdminPassword
Administrator password inside the VM. If not supplied, prompts interactively.
.PARAMETER BuildPassword
Password to set for the ci_build user inside the VM.
Must not be empty — script prompts interactively if not supplied.
.PARAMETER BuildUsername
Local username for the CI build account inside the VM. Default: ci_build.
Passed through to Setup-WinBuild2022.ps1 and used in post-setup validation.
.PARAMETER DotNetSdkVersion
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
.PARAMETER SkipWindowsUpdate
Pass through to Setup-WinBuild2022.ps1 to skip the Windows Update step.
.PARAMETER SnapshotName
Name of the snapshot to create. Default: BaseClean (case-sensitive — used by New-BuildVM.ps1).
Only relevant when -TakeSnapshot is set.
.PARAMETER StoreCredential
After provisioning completes, store BuildUsername/BuildPassword in Windows
Credential Manager (target: CredentialTarget). Installs the CredentialManager
module (CurrentUser scope) automatically if not present.
Equivalent to running manually:
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password '<pwd>' -Persist LocalMachine
.PARAMETER CredentialTarget
Windows Credential Manager target name used when -StoreCredential is set.
Default: BuildVMGuest (matches the target expected by Invoke-CIJob.ps1 and
the other CI scripts that load guest credentials via Get-StoredCredential).
.EXAMPLE
# Fully automated — VMX path only (power-on + IP detection + snapshot automatic):
.\Prepare-WinBuild2022.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
# Skip Windows Update (already applied):
.\Prepare-WinBuild2022.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
-SkipWindowsUpdate -StoreCredential
# Explicit IP (manual start, no VMware Tools required for IP detection):
.\Prepare-WinBuild2022.ps1 -VMIPAddress 192.168.79.130 `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
# Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress:
.\Prepare-WinBuild2022.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' -VMIPAddress 192.168.79.130 `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
#>
[CmdletBinding()]
param(
# Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot.
# Either -VMXPath or -VMIPAddress (or both) must be supplied.
[string] $VMXPath = '',
# Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via VMware Tools).
[string] $VMIPAddress = '',
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword = '',
[string] $BuildPassword = '',
[string] $BuildUsername = 'ci_build',
[string] $DotNetSdkVersion = '10.0',
[switch] $SkipWindowsUpdate,
# Skip disk cleanup inside the VM (cleanmgr + DISM + cache clear) — speeds up re-runs
[switch] $SkipCleanup,
# Snapshot name (case-sensitive — must match GITEA_CI_SNAPSHOT_NAME in runner/config.yaml)
[string] $SnapshotName = 'BaseClean',
# Store BuildUsername/BuildPassword in Windows Credential Manager after provisioning
[switch] $StoreCredential,
# Credential Manager target name (must match what CI scripts use via Get-StoredCredential)
[string] $CredentialTarget = 'BuildVMGuest'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Locate vmrun.exe ──────────────────────────────────────────────────────────
$vmrunCandidates = @(
'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
'C:\Program Files\VMware\VMware Workstation\vmrun.exe'
)
$vmrunExe = $vmrunCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $vmrunExe) {
$vmrunFound = Get-Command vmrun -ErrorAction SilentlyContinue
if ($vmrunFound) { $vmrunExe = $vmrunFound.Source }
}
if (-not $vmrunExe) { throw "[Prepare] vmrun.exe not found. Install VMware Workstation or add it to PATH." }
# ── Validate input: need VMXPath or VMIPAddress ───────────────────────────────
if ($VMXPath -eq '' -and $VMIPAddress -eq '') {
throw "[Prepare] Provide -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)."
}
# ── Power on VM and detect IP via VMXPath ────────────────────────────────────
$vmWasPoweredOn = $false
if ($VMXPath -ne '') {
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" }
# Check if already running
$runningList = @(& $vmrunExe list 2>&1 |
Where-Object { $_ -match '\.vmx$' } | ForEach-Object { $_.Trim() })
if ($runningList -notcontains $VMXPath) {
Write-Host "[Prepare] VM not running — starting: $VMXPath"
& $vmrunExe start $VMXPath
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start exited $LASTEXITCODE." }
$vmWasPoweredOn = $true
Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..."
}
else {
Write-Host "[Prepare] VM already running: $VMXPath"
}
# Auto-detect IP if not supplied
if ($VMIPAddress -eq '') {
Write-Host "[Prepare] Detecting guest IP via VMware Tools (getGuestIPAddress -wait)..."
$detectedIP = (& $vmrunExe getGuestIPAddress $VMXPath -wait 2>&1).Trim()
if ($detectedIP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
throw "[Prepare] Could not get guest IP. Got: '$detectedIP'. Ensure VMware Tools is installed."
}
$VMIPAddress = $detectedIP
Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green
}
}
function Assert-Step {
param(
[Parameter(Mandatory)] [string] $StepName,
[Parameter(Mandatory)] [string] $Description,
[Parameter(Mandatory)] [scriptblock] $Test
)
$ok = $false
try { $ok = [bool](& $Test) }
catch { throw "[Prepare/$StepName] Validation threw on '$Description': $_" }
if (-not $ok) {
throw "[Prepare/$StepName] Validation failed: $Description"
}
Write-Host " [OK] $Description" -ForegroundColor Green
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Pre-flight validation
Assert-Step 'PreFlight' 'script directory resolved' { Test-Path $scriptDir -PathType Container }
Assert-Step 'PreFlight' 'Setup-WinBuild2022.ps1 present alongside this script' {
Test-Path (Join-Path $scriptDir 'Setup-WinBuild2022.ps1') -PathType Leaf
}
Assert-Step 'PreFlight' "VMIPAddress octets in 0..255" {
$parts = $VMIPAddress.Split('.') | ForEach-Object { [int]$_ }
($parts.Count -eq 4) -and -not ($parts | Where-Object { $_ -lt 0 -or $_ -gt 255 })
}
# WinRM readiness — retry loop (longer timeout if VM was just powered on by this script)
$winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 }
Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5986 (timeout ${winrmTimeoutSec}s)..."
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
$winrmReady = $false
while ([datetime]::UtcNow -lt $winrmDeadline) {
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 `
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
$winrmReady = $true; break
}
Start-Sleep -Seconds 5
Write-Host " ... waiting for WinRM..."
}
Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady }
# Prompt for BuildPassword if not supplied — never use a hardcoded default.
if ($BuildPassword -eq '') {
Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:"
$secPwd = Read-Host -Prompt " BuildPassword" -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
$BuildPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
# ── Build PSCredential ────────────────────────────────────────────────────────
if ($AdminPassword -eq '') {
Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):"
$credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)"
}
else {
$secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd)
}
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
# ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ──────
# HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts
# needs appending so PS accepts the non-domain VM. Restored in the finally block.
$prevTrustedHosts = $null
try {
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
# Add VM IP to TrustedHosts if not already present
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
}
Write-Host "[Prepare] Host TrustedHosts configured."
Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" {
$th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
$th -eq '*' -or $th -like "*$VMIPAddress*"
}
}
catch {
Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)."
Write-Warning "Run once in an elevated PowerShell on the HOST:"
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
Write-Warning "Then re-run this script."
exit 1
}
# ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ──
# New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert.
Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..."
$session = try {
New-PSSession `
-ComputerName $VMIPAddress `
-Credential $credential `
-SessionOption $sessionOptions `
-UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
} catch {
Write-Error @"
[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
Ensure:
- The VM is running and on VMnet8 (NAT) with internet access
- WinRM HTTPS is enabled inside the VM Deploy-WinBuild2022.ps1 post-install.ps1
must have run successfully (creates self-signed cert + HTTPS listener on 5986)
- Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest)
- The IP is correct (check ipconfig inside the VM)
Error: $_
"@
exit 1
}
try {
# ── Copy Setup-WinBuild2022.ps1 into the VM ───────────────────────────────
$setupScript = Join-Path $scriptDir 'Setup-WinBuild2022.ps1'
$guestScriptPath = 'C:\CI\Setup-WinBuild2022.ps1'
Write-Host "[Prepare] Ensuring C:\CI exists on guest..."
Invoke-Command -Session $session -ScriptBlock {
if (-not (Test-Path 'C:\CI')) {
New-Item -ItemType Directory -Path 'C:\CI' -Force | Out-Null
}
}
Assert-Step 'GuestPrep' 'C:\CI exists on guest' {
Invoke-Command -Session $session -ScriptBlock { Test-Path 'C:\CI' -PathType Container }
}
Write-Host "[Prepare] Copying Setup-WinBuild2022.ps1 -> guest $guestScriptPath (UTF-8 BOM)..."
# Do NOT use Copy-Item -ToSession: it binary-copies the file.
# If the source file has no UTF-8 BOM, PowerShell 5.1 on the guest reads it as
# Windows-1252, misinterpreting multi-byte UTF-8 sequences (e.g. em dash U+2014
# encodes as E2 80 94; byte 0x94 in Win-1252 = curly right-quote, which PS 5.1
# treats as a string terminator → parse errors throughout the script).
#
# Fix: read content on host as Unicode string, transmit via WinRM (XML/Unicode),
# write on guest with explicit UTF-8 BOM via System.Text.UTF8Encoding($true).
# PS 5.1 detects the BOM and reads the file as UTF-8 correctly.
$scriptContent = [System.IO.File]::ReadAllText($setupScript, [System.Text.Encoding]::UTF8)
Invoke-Command -Session $session -ScriptBlock {
param($content, $path)
$utf8Bom = New-Object System.Text.UTF8Encoding($true) # $true = emit BOM
[System.IO.File]::WriteAllText($path, $content, $utf8Bom)
} -ArgumentList $scriptContent, $guestScriptPath
# Validation — file present and large enough (BOM transfer changes byte count vs host)
$hostSize = (Get-Item $setupScript).Length
Assert-Step 'GuestPrep' "guest script present at $guestScriptPath" {
Invoke-Command -Session $session -ScriptBlock { param($p) Test-Path $p -PathType Leaf } -ArgumentList $guestScriptPath
}
Assert-Step 'GuestPrep' "guest script size reasonable (host=$hostSize bytes ±6 for BOM)" {
$remoteSize = Invoke-Command -Session $session -ScriptBlock {
param($p) (Get-Item $p -ErrorAction SilentlyContinue).Length
} -ArgumentList $guestScriptPath
# UTF-8 BOM adds 3 bytes; allow small variance
[int64]$remoteSize -ge ([int64]$hostSize - 6) -and [int64]$remoteSize -le ([int64]$hostSize + 6)
}
# ── Build argument list for Setup-WinBuild2022.ps1 ───────────────────────
$setupArgs = @{
BuildPassword = $BuildPassword
BuildUsername = $BuildUsername
DotNetSdkVersion = $DotNetSdkVersion
AdminPassword = $AdminPassword
}
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
# ── Run Setup-WinBuild2022.ps1 inside the VM ──────────────────────────────
# Setup may return exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) when Windows
# Update applied patches that need a reboot. We loop: reboot guest, wait for
# WinRM, reopen session, re-invoke Setup. Steps 1-5 are idempotent (~30 s);
# Step 6 picks up where the previous run left off (no leftover updates →
# exit 0). Hard cap on iterations to avoid an infinite loop.
$maxWuIterations = 10
$rebootDeadlineMin = 20
function Invoke-GuestSetup {
param([Parameter(Mandatory)] $Session,
[Parameter(Mandatory)] [string] $ScriptPath,
[Parameter(Mandatory)] [hashtable] $ScriptArgs)
Invoke-Command -Session $Session -ScriptBlock {
param($scriptPath, $scriptArgs)
Set-ExecutionPolicy Bypass -Scope Process -Force
$exitCode = 0
try {
& $scriptPath @scriptArgs
$exitCode = $LASTEXITCODE
if ($null -eq $exitCode) { $exitCode = 0 }
} catch {
Write-Error $_
$exitCode = 1
}
return $exitCode
} -ArgumentList $ScriptPath, $ScriptArgs
}
function Wait-GuestWinRMReady {
param([Parameter(Mandatory)] [string] $IP,
[Parameter(Mandatory)] $Cred,
[Parameter(Mandatory)] $SessionOptions,
[int] $TimeoutMin = 20)
$deadline = (Get-Date).AddMinutes($TimeoutMin)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 10
try {
# Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe.
$probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL `
-SessionOption $SessionOptions -Credential $Cred `
-Authentication Basic -ErrorAction Stop
Remove-PSSession $probe -ErrorAction SilentlyContinue
return $true
} catch { }
}
return $false
}
Write-Host "[Prepare] Running Setup-WinBuild2022.ps1 inside VM (this will take a while)..."
for ($iter = 1; $iter -le $maxWuIterations; $iter++) {
Write-Host "[Prepare] Setup iteration $iter/$maxWuIterations"
Write-Host "[Prepare] Output from guest:"
Write-Host "------------------------------------------------------------"
$result = $null
try {
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
# VM rebooted mid-update (WU triggered OS restart before Setup could return 3010).
# Treat as reboot-required: close dead session, fall through to the reboot-wait block.
Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..."
Remove-PSSession $session -ErrorAction SilentlyContinue
$session = $null
$result = 3010
}
Write-Host "------------------------------------------------------------"
Write-Host "[Prepare] Iteration $iter exit code: $result"
if ($result -eq 0) { break }
if ($result -ne 3010) {
throw "Setup-WinBuild2022.ps1 exited with code $result"
}
# exit 3010 (or transport error treated as 3010) → WU needs reboot.
# Reboot guest if session still alive, wait for WinRM, reopen session, loop.
if ($iter -eq $maxWuIterations) {
throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)"
}
Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..."
if ($session) {
try {
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue
} catch { }
Remove-PSSession $session -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 30 # let WinRM tear down before polling
Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..."
if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential `
-SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) {
throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot"
}
Write-Host "[Prepare] Guest WinRM ready, reopening session..."
$session = New-PSSession -ComputerName $VMIPAddress -Credential $credential `
-SessionOption $sessionOptions -UseSSL -Port 5986 `
-Authentication Basic -ErrorAction Stop
}
# ── Post-setup remote validation ──────────────────────────────────────────
Write-Host "[Prepare] Running post-setup validation against guest..."
$guestState = Invoke-Command -Session $session -ScriptBlock {
param($BuildUser)
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH','User')
[PSCustomObject]@{
WinRMRunning = (Get-Service WinRM -ErrorAction SilentlyContinue).Status -eq 'Running'
UserExists = [bool](Get-LocalUser -Name $BuildUser -ErrorAction SilentlyContinue)
UserIsAdmin = [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUser)
UACDisabled = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA -eq 0
FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true)
DotNetVersion = (Get-Command dotnet -ErrorAction SilentlyContinue) | ForEach-Object { (& $_.Source --version 2>&1) }
PythonExists = Test-Path 'C:\Python\python.exe' -PathType Leaf
MSBuildExists = Test-Path $msbuildPath -PathType Leaf
CIDirsPresent = (Test-Path 'C:\CI\build') -and (Test-Path 'C:\CI\output') -and (Test-Path 'C:\CI\scripts')
}
} -ArgumentList $BuildUsername
Assert-Step 'PostSetup' 'guest WinRM Running' { $guestState.WinRMRunning }
Assert-Step 'PostSetup' "guest user '$BuildUsername' exists" { $guestState.UserExists }
Assert-Step 'PostSetup' "guest user '$BuildUsername' is admin" { $guestState.UserIsAdmin }
Assert-Step 'PostSetup' 'guest UAC disabled' { $guestState.UACDisabled }
Assert-Step 'PostSetup' 'guest firewall disabled' { $guestState.FirewallOff }
Assert-Step 'PostSetup' 'guest .NET SDK installed' { [bool]$guestState.DotNetVersion }
Assert-Step 'PostSetup' 'guest Python installed' { $guestState.PythonExists }
Assert-Step 'PostSetup' 'guest MSBuild present' { $guestState.MSBuildExists }
Assert-Step 'PostSetup' 'guest C:\CI\{build,output,scripts} present' { $guestState.CIDirsPresent }
Write-Host "[Prepare] All post-setup validations passed (.NET $($guestState.DotNetVersion))." -ForegroundColor Green
# ── Store credentials in Windows Credential Manager (optional) ────────────
if ($StoreCredential) {
Write-Host "[Prepare] Storing CI credentials in Windows Credential Manager (target: '$CredentialTarget')..."
if (-not (Get-Module -ListAvailable -Name CredentialManager)) {
Write-Host "[Prepare] CredentialManager module not found — installing (CurrentUser scope)..."
Install-Module -Name CredentialManager -Scope CurrentUser -Force -ErrorAction Stop
}
Import-Module CredentialManager -ErrorAction Stop
New-StoredCredential `
-Target $CredentialTarget `
-UserName $BuildUsername `
-Password $BuildPassword `
-Persist LocalMachine `
-ErrorAction Stop | Out-Null
Write-Host "[Prepare] Credentials stored: target='$CredentialTarget' user='$BuildUsername'." -ForegroundColor Green
}
Write-Host "[Prepare] All validations passed." -ForegroundColor Green
# ── Shutdown + snapshot prompt ─────────────────────────────────────────────
# vmrun.exe already located at script start ($vmrunExe)
# Auto-discover VMX path via 'vmrun list' while VM is still running (if not known)
if ($VMXPath -eq '') {
Write-Host "[Prepare] Discovering VMX path from running VMs..."
$runningVMs = @(& $vmrunExe list 2>&1 |
Where-Object { $_ -match '\.vmx$' } |
ForEach-Object { $_.Trim() })
if ($runningVMs.Count -eq 0) {
throw "[Prepare] No running VMs found via 'vmrun list'. Is the template VM still up?"
}
elseif ($runningVMs.Count -eq 1) {
$VMXPath = $runningVMs[0]
Write-Host "[Prepare] VMX auto-detected: $VMXPath" -ForegroundColor Green
}
else {
# Multiple VMs — prefer one under \CI\Templates\
$ciVMs = @($runningVMs | Where-Object { $_ -like '*\CI\Templates\*' })
if ($ciVMs.Count -eq 1) {
$VMXPath = $ciVMs[0]
Write-Host "[Prepare] VMX auto-detected (CI template): $VMXPath" -ForegroundColor Green
}
else {
Write-Host "[Prepare] Multiple VMs running — select one:"
for ($i = 0; $i -lt $runningVMs.Count; $i++) {
Write-Host " [$($i+1)] $($runningVMs[$i])"
}
$idx = [int](Read-Host "[Prepare] Enter number") - 1
if ($idx -lt 0 -or $idx -ge $runningVMs.Count) { throw "[Prepare] Invalid selection." }
$VMXPath = $runningVMs[$idx]
}
}
}
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found on disk: '$VMXPath'" }
# Prompt
Write-Host "------------------------------------------------------------"
Write-Host ""
$choice = Read-Host "[Prepare] Shut down VM and create snapshot '$SnapshotName'? [Y/N]"
if ($choice -match '^[Yy]') {
# Send graceful shutdown
Write-Host "[Prepare] Sending graceful shutdown to VM..."
try {
Invoke-Command -Session $session -ScriptBlock { Stop-Computer -Force } -ErrorAction Stop
Write-Host "[Prepare] Shutdown command sent." -ForegroundColor Green
}
catch {
Write-Warning "[Prepare] Shutdown command failed (VM may have already shut down): $_"
}
# Close session — VM is shutting down, no further WinRM needed
Remove-PSSession $session -ErrorAction SilentlyContinue
# Wait for VM to power off (poll vmrun list)
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
$shutdownDeadline = [datetime]::UtcNow.AddSeconds(120)
$poweredOff = $false
while ([datetime]::UtcNow -lt $shutdownDeadline) {
$runningList = & $vmrunExe list 2>&1 | Out-String
if ($runningList -notmatch [regex]::Escape($VMXPath)) { $poweredOff = $true; break }
Start-Sleep -Seconds 5
Write-Host " ... waiting for shutdown..."
}
if (-not $poweredOff) { throw "[Prepare] VM did not power off within 120 s. Check VMware Workstation." }
# Check for existing snapshot with same name
$existingSnaps = & $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String
$doCreate = $true
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
Write-Warning "[Prepare] Snapshot '$SnapshotName' already exists."
$delChoice = Read-Host "[Prepare] Delete existing snapshot and recreate? [Y/N]"
if ($delChoice -match '^[Yy]') {
Write-Host "[Prepare] Deleting existing snapshot '$SnapshotName'..."
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun deleteSnapshot exited $LASTEXITCODE." }
Write-Host "[Prepare] Existing snapshot deleted." -ForegroundColor Green
}
else {
Write-Host "[Prepare] Keeping existing snapshot — skipping creation." -ForegroundColor Yellow
$doCreate = $false
}
}
# Create snapshot
if ($doCreate) {
Write-Host "[Prepare] Creating snapshot '$SnapshotName'..." -ForegroundColor Green
& $vmrunExe -T ws snapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun snapshot exited $LASTEXITCODE." }
Write-Host "[Prepare] Snapshot '$SnapshotName' created successfully." -ForegroundColor Green
}
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
if ($StoreCredential) {
Write-Host " [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
}
Write-Host ""
}
else {
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " MANUAL STEPS REQUIRED:"
Write-Host ""
Write-Host " 1. Shut down the VM: Start -> Shut down"
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
Write-Host ""
Write-Host " 2. Take snapshot in VMware Workstation:"
Write-Host " VM -> Snapshot -> Take Snapshot"
Write-Host " Name it EXACTLY: $SnapshotName"
Write-Host ""
if ($StoreCredential) {
Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
}
else {
Write-Host " 3. Store CI credentials on this HOST (run in elevated PowerShell):"
Write-Host " .\Prepare-WinBuild2022.ps1 -VMIPAddress $VMIPAddress -StoreCredential"
Write-Host " (or manually:)"
Write-Host " New-StoredCredential -Target '$CredentialTarget' ``"
Write-Host " -UserName '$BuildUsername' -Password '<your-build-password>' ``"
Write-Host " -Persist LocalMachine"
}
Write-Host ""
}
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
# Restore host TrustedHosts to its original value
if ($null -ne $prevTrustedHosts) {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
}
Write-Host "[Prepare] Host TrustedHosts restored."
}
+64 -65
View File
@@ -5,11 +5,12 @@
.DESCRIPTION .DESCRIPTION
Automates the template VM provisioning from the host machine: Automates the template VM provisioning from the host machine:
1. Pre-flight validation: script presence, IP octet range, TCP/5985 reachable 1. Pre-flight validation: script presence, IP octet range, TCP/5986 reachable
2. Configures host WinRM client (AllowUnencrypted, TrustedHosts) — restored on exit 2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) — restored on exit
3. Validates host WinRM client settings applied correctly 3. Validates host WinRM TrustedHosts applied correctly
4. Tests WinRM connectivity (Test-WSMan) — fails fast with actionable message 4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck — fails fast with actionable message
5. Opens PSSession to the VM (PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe)
5. PSSession open — step 4 and 5 are now merged
6. Ensures C:\CI exists on guest; validates creation 6. Ensures C:\CI exists on guest; validates creation
7. Copies Setup-WinBuild2025.ps1 into the VM; validates file present + size match 7. Copies Setup-WinBuild2025.ps1 into the VM; validates file present + size match
8. Runs Setup-WinBuild2025.ps1 inside the VM with the given parameters 8. Runs Setup-WinBuild2025.ps1 inside the VM with the given parameters
@@ -17,7 +18,7 @@
9. Handles the reboot-required case (exit 3010) and optionally re-runs 9. Handles the reboot-required case (exit 3010) and optionally re-runs
10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command 10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command
(WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs) (WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs)
11. Restores host WinRM client settings in finally block 11. Restores host TrustedHosts in finally block
Every validation step uses Assert-Step: prints [OK] on success, throws on failure. Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
The snapshot should only be taken after this script exits 0. The snapshot should only be taken after this script exits 0.
@@ -227,18 +228,18 @@ Assert-Step 'PreFlight' "VMIPAddress octets in 0..255" {
# WinRM readiness — retry loop (longer timeout if VM was just powered on by this script) # WinRM readiness — retry loop (longer timeout if VM was just powered on by this script)
$winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 } $winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 }
Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5985 (timeout ${winrmTimeoutSec}s)..." Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5986 (timeout ${winrmTimeoutSec}s)..."
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec) $winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
$winrmReady = $false $winrmReady = $false
while ([datetime]::UtcNow -lt $winrmDeadline) { while ([datetime]::UtcNow -lt $winrmDeadline) {
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5985 ` if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 `
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) { -InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
$winrmReady = $true; break $winrmReady = $true; break
} }
Start-Sleep -Seconds 5 Start-Sleep -Seconds 5
Write-Host " ... waiting for WinRM..." Write-Host " ... waiting for WinRM..."
} }
Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5985" { $winrmReady } Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady }
# Prompt for BuildPassword if not supplied — never use a hardcoded default. # Prompt for BuildPassword if not supplied — never use a hardcoded default.
if ($BuildPassword -eq '') { if ($BuildPassword -eq '') {
@@ -261,59 +262,53 @@ else {
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck $sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
# ── Configure HOST-side WinRM client (required for Basic auth over HTTP) ────── # ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ──────
# AllowUnencrypted is needed for HTTP/5985 Basic auth. We save the prior value # HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts
# and restore it in the finally block so this script doesn't permanently change # needs appending so PS accepts the non-domain VM. Restored in the finally block.
# the host security posture. $prevTrustedHosts = $null
Write-Host "[Prepare] Configuring host WinRM client for unencrypted Basic auth (will be restored on exit)..."
$prevAllowUnencrypted = $null
$prevTrustedHosts = $null
try { try {
$prevAllowUnencrypted = (Get-Item WSMan:\localhost\Client\AllowUnencrypted -ErrorAction Stop).Value $prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force -ErrorAction Stop
# Add VM IP to TrustedHosts if not already present # Add VM IP to TrustedHosts if not already present
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") { if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" } $newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
} }
Write-Host "[Prepare] Host WinRM client configured." Write-Host "[Prepare] Host TrustedHosts configured."
# Validation
Assert-Step 'HostWinRM' 'Client/AllowUnencrypted=true' {
(Get-Item WSMan:\localhost\Client\AllowUnencrypted).Value -eq 'true'
}
Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" { Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" {
$th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value $th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
$th -eq '*' -or $th -like "*$VMIPAddress*" $th -eq '*' -or $th -like "*$VMIPAddress*"
} }
} }
catch { catch {
Write-Warning "[Prepare] Could not configure host WinRM client settings (need elevation?)." Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)."
Write-Warning "Run once in an elevated PowerShell on the HOST:" Write-Warning "Run once in an elevated PowerShell on the HOST:"
Write-Warning " Set-Item WSMan:\localhost\Client\AllowUnencrypted `$true -Force"
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force" Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
Write-Warning "Then re-run this script." Write-Warning "Then re-run this script."
exit 1 exit 1
} }
Write-Host "[Prepare] Testing WinRM connectivity to $VMIPAddress..." # ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ──
try { # New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert.
Test-WSMan -ComputerName $VMIPAddress -Credential $credential ` Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..."
-Authentication Basic -ErrorAction Stop | Out-Null $session = try {
Write-Host "[Prepare] WinRM reachable." New-PSSession `
} -ComputerName $VMIPAddress `
catch { -Credential $credential `
-SessionOption $sessionOptions `
-UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
} catch {
Write-Error @" Write-Error @"
[Prepare] Cannot reach $VMIPAddress via WinRM. [Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
Ensure: Ensure:
- The VM is running and on VMnet8 (NAT) with internet access - The VM is running and on VMnet8 (NAT) with internet access
- WinRM is enabled inside the VM (run as Administrator in VM): - WinRM HTTPS is enabled inside the VM Deploy-WinBuild2025.ps1 post-install.ps1
winrm quickconfig -q must have run successfully (creates self-signed cert + HTTPS listener on 5986)
Enable-PSRemoting -Force -SkipNetworkProfileCheck - Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest)
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
- Windows Firewall allows port 5985 inbound
- The IP is correct (check ipconfig inside the VM) - The IP is correct (check ipconfig inside the VM)
Error: $_ Error: $_
@@ -321,15 +316,6 @@ Error: $_
exit 1 exit 1
} }
# ── Open session ──────────────────────────────────────────────────────────────
Write-Host "[Prepare] Opening PSSession..."
$session = New-PSSession `
-ComputerName $VMIPAddress `
-Credential $credential `
-SessionOption $sessionOptions `
-Authentication Basic `
-ErrorAction Stop
try { try {
# ── Copy Setup-WinBuild2025.ps1 into the VM ─────────────────────────────── # ── Copy Setup-WinBuild2025.ps1 into the VM ───────────────────────────────
$setupScript = Join-Path $scriptDir 'Setup-WinBuild2025.ps1' $setupScript = Join-Path $scriptDir 'Setup-WinBuild2025.ps1'
@@ -380,6 +366,7 @@ try {
BuildPassword = $BuildPassword BuildPassword = $BuildPassword
BuildUsername = $BuildUsername BuildUsername = $BuildUsername
DotNetSdkVersion = $DotNetSdkVersion DotNetSdkVersion = $DotNetSdkVersion
AdminPassword = $AdminPassword
} }
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true } if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true } if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
@@ -422,9 +409,12 @@ try {
while ((Get-Date) -lt $deadline) { while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 10 Start-Sleep -Seconds 10
try { try {
$r = Test-WSMan -ComputerName $IP -Credential $Cred ` # Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe.
-Authentication Basic -ErrorAction Stop $probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL `
if ($r) { return $true } -SessionOption $SessionOptions -Credential $Cred `
-Authentication Basic -ErrorAction Stop
Remove-PSSession $probe -ErrorAction SilentlyContinue
return $true
} catch { } } catch { }
} }
return $false return $false
@@ -437,7 +427,17 @@ try {
Write-Host "[Prepare] Output from guest:" Write-Host "[Prepare] Output from guest:"
Write-Host "------------------------------------------------------------" Write-Host "------------------------------------------------------------"
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs $result = $null
try {
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
# VM rebooted mid-update (WU triggered OS restart before Setup could return 3010).
# Treat as reboot-required: close dead session, fall through to the reboot-wait block.
Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..."
Remove-PSSession $session -ErrorAction SilentlyContinue
$session = $null
$result = 3010
}
Write-Host "------------------------------------------------------------" Write-Host "------------------------------------------------------------"
Write-Host "[Prepare] Iteration $iter exit code: $result" Write-Host "[Prepare] Iteration $iter exit code: $result"
@@ -448,18 +448,19 @@ try {
throw "Setup-WinBuild2025.ps1 exited with code $result" throw "Setup-WinBuild2025.ps1 exited with code $result"
} }
# exit 3010 → WU applied updates needing reboot. Reboot guest, wait, # exit 3010 (or transport error treated as 3010) → WU needs reboot.
# reopen session, loop. -SkipWindowsUpdate must NOT be set on retry — # Reboot guest if session still alive, wait for WinRM, reopen session, loop.
# we want the next iteration to drain remaining updates.
if ($iter -eq $maxWuIterations) { if ($iter -eq $maxWuIterations) {
throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)" throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)"
} }
Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..." Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..."
try { if ($session) {
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue try {
} catch { } Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue
Remove-PSSession $session -ErrorAction SilentlyContinue } catch { }
Remove-PSSession $session -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 30 # let WinRM tear down before polling Start-Sleep -Seconds 30 # let WinRM tear down before polling
Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..." Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..."
@@ -469,7 +470,8 @@ try {
} }
Write-Host "[Prepare] Guest WinRM ready, reopening session..." Write-Host "[Prepare] Guest WinRM ready, reopening session..."
$session = New-PSSession -ComputerName $VMIPAddress -Credential $credential ` $session = New-PSSession -ComputerName $VMIPAddress -Credential $credential `
-SessionOption $sessionOptions -Authentication Basic -ErrorAction Stop -SessionOption $sessionOptions -UseSSL -Port 5986 `
-Authentication Basic -ErrorAction Stop
} }
# ── Post-setup remote validation ────────────────────────────────────────── # ── Post-setup remote validation ──────────────────────────────────────────
@@ -658,12 +660,9 @@ try {
finally { finally {
Remove-PSSession $session -ErrorAction SilentlyContinue Remove-PSSession $session -ErrorAction SilentlyContinue
# Restore host WinRM client settings to their original values # Restore host TrustedHosts to its original value
if ($null -ne $prevAllowUnencrypted) {
Set-Item WSMan:\localhost\Client\AllowUnencrypted $prevAllowUnencrypted -Force -ErrorAction SilentlyContinue
}
if ($null -ne $prevTrustedHosts) { if ($null -ne $prevTrustedHosts) {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
} }
Write-Host "[Prepare] Host WinRM client settings restored." Write-Host "[Prepare] Host TrustedHosts restored."
} }
File diff suppressed because it is too large Load Diff
+236 -177
View File
@@ -8,40 +8,42 @@
This script is run ONE TIME inside the template VM before taking the This script is run ONE TIME inside the template VM before taking the
"BaseClean" snapshot. After the snapshot is taken, never modify the VM. "BaseClean" snapshot. After the snapshot is taken, never modify the VM.
Installs and configures (each step followed by Assert-Step validation): Prerequisite: Deploy-WinBuild2025.ps1 must have completed first (unattended OS
Step 1 — Firewall: all profiles disabled first — removes any block on subsequent steps install + post-install.ps1 hardening). Steps 1, 3, 4b, 5b, 5c are validation-only
— they assert that Deploy already set the expected state rather than re-applying it.
Steps 4, 5, 6/6b, 7-10 perform actual CI customisation.
Steps and Assert-Step validation:
Step 1 — Firewall: validation only — Deploy disabled all profiles.
Validation: Domain/Private/Public all Enabled=False Validation: Domain/Private/Public all Enabled=False
Step 2 — Defender state validation only (disabled by Deploy-WinBuild2025 Step 2 — Defender: validation only — Deploy set DisableAntiSpyware=1 GPO + RTP off.
during unattended install via DisableAntiSpyware GPO + RTP off). Validation: GPO DisableAntiSpyware=1
Validation: GPO DisableAntiSpyware=1. No exclusion paths — with Step 3 — WinRM: validation only — Deploy ran Enable-PSRemoting, Basic auth over HTTPS,
Defender off the scanner isn't running, so exclusions are moot. AllowUnencrypted=false (HTTPS-only), MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=25.
Step 3 — WinRM: Basic auth, AllowUnencrypted, MaxMemoryPerShellMB=2048, 2h timeout Validation: service Running+Automatic, AllowUnencrypted=false, Auth/Basic, memory
Firewall + Defender already off — no interference during config Step 3b — Windows KMS activation via slmgr /skms + /ato (best-effort, non-fatal).
Validation: service Running+Automatic, AllowUnencrypted, Auth/Basic, memory Validation: LicenseStatus=1 check (warning-only on failure)
Step 4 — Local build user account ($BuildUsername, PasswordNeverExpires, Administrators) Step 4 — Local build user account ($BuildUsername, PasswordNeverExpires, Administrators)
Validation: user exists, enabled, PasswordNeverExpires, admin membership Validation: user exists, enabled, PasswordNeverExpires, admin membership
Step 4b — UAC disabled (EnableLUA=0, LocalAccountTokenFilterPolicy=1) Step 4b — UAC: validation only — Deploy set EnableLUA=0, LocalAccountTokenFilterPolicy=1.
Validation: both registry values verified Validation: both registry values verified
Step 5 — CI working directories: C:\CI\build, C:\CI\output, C:\CI\scripts Step 5 — CI working directories: C:\CI\build, C:\CI\output, C:\CI\scripts
Must run before Windows Update — WU worker writes temp files to C:\CI Must run before Windows Update — WU worker writes temp files to C:\CI
Validation: each path exists as Container Validation: each path exists as Container
Step 5b — Explorer LaunchTo=1 (This PC) for current user + Default profile hive Step 5b — Explorer LaunchTo=1: validation only — Deploy set HKCU + Default hive.
Validation: HKCU LaunchTo=1 Validation: HKCU LaunchTo=1
Step 5c — CI UX hardening (all UI tweaks before the long-running WU step): Step 5c — CI UX hardening: validation only — Deploy set lock screen, Server Manager
Server Manager auto-start off (machine policy + HKCU Administrator + (policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System
Default User hive → ci_build inherits at first logon) + Winlogon), OOBE/telemetry suppressed.
Ctrl+Alt+Del at login disabled (DisableCAD=1) — CI VMs never need it Validation: NoLockScreen, SM policy, SM task, DisableCAD, OOBE, telemetry
OOBE privacy/telemetry prompt suppressed (DisablePrivacyExperience=1, Step 6 — Windows Update via Scheduled Task as SYSTEM (avoids WinRM token limit).
AllowTelemetry=0) — prevents first-logon interactive prompt in clones Clears Deploy's GPO locks (Step A), starts services (Step D), runs COM API,
Validation: machine policy key, HKCU key, DisableCAD, OOBE policy, telemetry reports result code. Skipped with -SkipWindowsUpdate.
Step 6 — Windows Update via Scheduled Task as SYSTEM (avoids WinRM token limit)
Runs with Firewall + Defender off, C:\CI present — faster, no scan overhead
Validation: result code 0/2/3, no reboot required (else exit 3010) Validation: result code 0/2/3, no reboot required (else exit 3010)
Step 6b — Windows Update permanently disabled (post-update lockdown) Step 6b — Windows Update permanently disabled (post-update lockdown — always runs).
wuauserv + UsoSvc set to Disabled; GPO keys NoAutoUpdate=1, wuauserv + UsoSvc set to Disabled; GPO keys NoAutoUpdate=1,
DisableWindowsUpdateAccess=1; WU scheduled tasks disabled DisableWindowsUpdateAccess=1; WU scheduled tasks disabled.
Runs unconditionally (even with -SkipWindowsUpdate) so snapshot Snapshot captures WU permanently off — linked clones never auto-update.
captures WU off — linked clones never trigger background updates
Validation: wuauserv StartType=Disabled, GPO keys present Validation: wuauserv StartType=Disabled, GPO keys present
Step 7 — .NET SDK (channel $DotNetSdkVersion) via dotnet-install.ps1 Step 7 — .NET SDK (channel $DotNetSdkVersion) via dotnet-install.ps1
Validation: dotnet resolvable, version channel match, machine PATH Validation: dotnet resolvable, version channel match, machine PATH
@@ -50,28 +52,27 @@
Step 9 — Visual Studio Build Tools 2026 via Scheduled Task as SYSTEM Step 9 — Visual Studio Build Tools 2026 via Scheduled Task as SYSTEM
Validation: MSBuild.exe exists, executes, PATH, VC dir present Validation: MSBuild.exe exists, executes, PATH, VC dir present
Step 10 — Toolchain cross-check (dotnet, python, msbuild) Step 10 — Toolchain cross-check (dotnet, python, msbuild)
Throws on any missing tool (was: warn-only) Throws on any missing tool
Cleanup — Disk cleanup: cleanmgr, SoftwareDistribution, CBS, TEMP, Prefetch, DISM Cleanup — Disk cleanup: cleanmgr, SoftwareDistribution, CBS, TEMP, Prefetch, DISM
wuauserv already Disabled (Step 6b) — not restarted after cache clear wuauserv already Disabled (Step 6b) — not restarted after cache clear
SoftwareDistribution\Download cleared best-effort (WaaSMedicSvc may repopulate) Validation: wuauserv StartType Disabled
Validation: wuauserv StartType Disabled (SoftwareDistribution check removed —
WaaSMedicSvc tamper-protection restores files immediately)
Final — Pre-snapshot gate: 7 cross-cutting checks across all steps Final — Pre-snapshot gate: 7 cross-cutting checks across all steps
Throws if any check fails — prevents snapshot of broken state Throws if any check fails — prevents snapshot of broken state
Step ordering rationale: Step ordering rationale:
Firewall first — removes any network block before WinRM config and WU downloads Firewall (Step 1) — validates Deploy disabled all profiles; assertion before WinRM/WU
Defender second — eliminates scan overhead before WinRM, WU, and installer downloads Defender (Step 2) — validates Deploy disabled RTP; assertion before tool install
WinRM third configured clean with no Firewall/Defender interference WinRM (Step 3) validates Deploy configured WinRM; assertion before remote steps
User + UAC — quick registry ops; UAC off before dirs/tools avoids elevation prompts User (Step 4) — creates ci_build; must exist before UAC and dirs assertions
CI dirs — C:\CI must exist before WU worker writes temp files there UAC (Step 4b) — validates Deploy disabled UAC (elevation-free installs in 7-9)
UI tweaks — all fast registry writes batched before the slow WU step CI dirs (Step 5) — C:\CI must exist before WU worker writes temp files there
WU sixth — all prereqs (dirs, user, tweaks) done; Firewall+Defender off UI tweaks (5b/5c)validates Deploy UX settings; fast assertions before slow WU
WU disable — immediately after WU completes; snapshot captures WU permanently off WU (Step 6) — all prereqs validated; WU runs with Firewall+Defender already off
Toolchain — .NET / Python / VS last; WU done, no scan on installer payloads WU disable (Step 6b) — immediately after WU; snapshot captures WU permanently off
Toolchain (7-9) — .NET/Python/VS last; WU done, no scan on installer payloads
NOTE: Git is NOT installed. The host clones the repository and copies NOTE: Git is NOT installed by default. See §6.6 (Tier-1 Toolchain) in TODO.md
the source tree into the VM via WinRM (Option B isolation model). for the planned addition. The host clones and copies source via WinRM until then.
╔══════════════════════════════════════════════════════════════════════╗ ╔══════════════════════════════════════════════════════════════════════╗
║ NETWORK REQUIREMENT DURING SETUP ║ ║ NETWORK REQUIREMENT DURING SETUP ║
@@ -132,7 +133,11 @@ param(
[switch] $SkipWindowsUpdate, [switch] $SkipWindowsUpdate,
# Skip disk cleanup step (cleanmgr + DISM + cache clear) — speeds up re-runs during dev/debug # Skip disk cleanup step (cleanmgr + DISM + cache clear) — speeds up re-runs during dev/debug
[switch] $SkipCleanup [switch] $SkipCleanup,
# Administrator password — used only to write DefaultPassword for autologin.
# Optional: if empty, DefaultPassword registry value is left as-is (Deploy may have set it).
[string] $AdminPassword = ''
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
@@ -158,6 +163,44 @@ function Assert-Step {
Write-Host " [OK] $Description" -ForegroundColor Green Write-Host " [OK] $Description" -ForegroundColor Green
} }
function Assert-Hash {
# Verifies SHA256 of a downloaded file against a pinned expected value.
# If $Expected is empty the check is skipped with a warning (unpinned installer).
# To pin: download the file, run (Get-FileHash <path> -Algorithm SHA256).Hash, fill in below.
param(
[Parameter(Mandatory)] [string] $FilePath,
[Parameter(Mandatory)] [string] $Label,
[string] $Expected = ''
)
if (-not $Expected) {
Write-Host " [WARN] Hash not pinned for $Label — set `$script:Hashes to enforce (see §1.3)" -ForegroundColor Yellow
return
}
$actual = (Get-FileHash $FilePath -Algorithm SHA256).Hash.ToUpper()
if ($actual -ne $Expected.ToUpper()) {
throw "Hash mismatch for ${Label}:`n expected: $($Expected.ToUpper())`n actual: $actual`nPossible MITM or wrong installer version — do NOT proceed."
}
Write-Host " [OK] SHA256 verified: $Label" -ForegroundColor Green
}
# ── Pinned SHA256 hashes for downloaded installers (§1.3) ─────────────────────
# Fill in the hash values below. Get them by:
# (Invoke-WebRequest '<URL>' -UseBasicParsing -OutFile 'tmp.bin'; (Get-FileHash 'tmp.bin' -Algorithm SHA256).Hash)
# Update when the corresponding version param changes. Leave '' to skip (warns at runtime).
$script:Hashes = @{
# python-<PythonVersion>-amd64.exe from https://www.python.org/downloads/release/python-XYZ/
# Update $script:Hashes['Python'] when $PythonVersion changes.
'Python' = ''
# dotnet-install.ps1 from https://dot.net/v1/dotnet-install.ps1
# Changes with each .NET SDK release cycle — verify after any dotnet version bump.
'DotNetInstallScript' = ''
# vs_buildtools.exe bootstrapper from aka.ms/vs/stable/vs_buildtools.exe
# Stable within a VS release cycle — update when VS major version changes.
'VSBuildToolsBootstrapper' = ''
}
# ── Pre-flight: remove temp files from any previous partial run ─────────────── # ── Pre-flight: remove temp files from any previous partial run ───────────────
# These files are normally deleted at the end of each step that creates them. # These files are normally deleted at the end of each step that creates them.
# A previous interrupted run may have left them behind — remove before starting. # A previous interrupted run may have left them behind — remove before starting.
@@ -173,16 +216,12 @@ foreach ($f in $knownTempFiles) {
} }
} }
# ── Step 1: Firewall ────────────────────────────────────────────────────────── # ── Step 1: Firewall (validation only — disabled by Deploy-WinBuild2025) ──────
Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM" Write-Step "Firewall state (validation onlydisabled at deploy time)"
# Disable first: removes any block on subsequent WinRM config and WU downloads. # Deploy-WinBuild2025.ps1 post-install.ps1 called Set-NetFirewallProfile -Enabled False
# This VM lives on VMnet8 NAT behind the VMware NAT gateway — no inbound exposure # on all profiles. Validated here before WinRM and WU assertions proceed.
# from outside the host, so full disable is acceptable.
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
Write-Host "Windows Firewall disabled on all profiles."
# Validation
foreach ($p in 'Domain','Private','Public') { foreach ($p in 'Domain','Private','Public') {
Assert-Step 'Firewall' "$p profile disabled" { Assert-Step 'Firewall' "$p profile disabled" {
(Get-NetFirewallProfile -Profile $p -ErrorAction Stop).Enabled -eq $false (Get-NetFirewallProfile -Profile $p -ErrorAction Stop).Enabled -eq $false
@@ -201,37 +240,21 @@ Assert-Step 'Defender' 'GPO DisableAntiSpyware=1 (set by Deploy-WinBuild2025)' {
((Get-ItemProperty -Path $key -Name DisableAntiSpyware -ErrorAction SilentlyContinue).DisableAntiSpyware -eq 1) ((Get-ItemProperty -Path $key -Name DisableAntiSpyware -ErrorAction SilentlyContinue).DisableAntiSpyware -eq 1)
} }
# ── Step 3: WinRM ───────────────────────────────────────────────────────────── # ── Step 3: WinRM (validation only — configured by Deploy-WinBuild2025) ───────
Write-Step "Configuring WinRM" Write-Step "WinRM state (validation only — configured at deploy time)"
# Firewall and Defender already off — no interference during WinRM hardening. # Deploy-WinBuild2025.ps1 post-install.ps1 ran Enable-PSRemoting, set Basic auth over HTTPS,
Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null # AllowUnencrypted=false (HTTPS/5986 only), TrustedHosts=*, MaxMemory=2048MB, IdleTimeout=2h,
# MaxProcesses=25, StartupType=Automatic. Validated here before the build user and toolchain steps.
# Allow unencrypted (HTTP/5985) — acceptable for an isolated lab network.
# Migrate to HTTPS/5986 with a self-signed cert for hardened environments.
winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
# Increase shell memory and timeout limits for long builds
winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null
Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>$null
Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>$null # 2 hours
# Ensure WinRM starts automatically
Set-Service -Name WinRM -StartupType Automatic 3>$null
Start-Service WinRM 3>$null
Write-Host "WinRM configured."
# Validation
Assert-Step 'WinRM' 'service Running' { Assert-Step 'WinRM' 'service Running' {
(Get-Service WinRM -ErrorAction Stop).Status -eq 'Running' (Get-Service WinRM -ErrorAction Stop).Status -eq 'Running'
} }
Assert-Step 'WinRM' 'service StartType Automatic' { Assert-Step 'WinRM' 'service StartType Automatic' {
(Get-Service WinRM -ErrorAction Stop).StartType -eq 'Automatic' (Get-Service WinRM -ErrorAction Stop).StartType -eq 'Automatic'
} }
Assert-Step 'WinRM' 'AllowUnencrypted=true' { Assert-Step 'WinRM' 'AllowUnencrypted=false (HTTPS-only)' {
(Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'true' (Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'false'
} }
Assert-Step 'WinRM' 'Auth/Basic=true' { Assert-Step 'WinRM' 'Auth/Basic=true' {
(Get-Item WSMan:\localhost\Service\Auth\Basic).Value -eq 'true' (Get-Item WSMan:\localhost\Service\Auth\Basic).Value -eq 'true'
@@ -240,6 +263,33 @@ Assert-Step 'WinRM' 'MaxMemoryPerShellMB >= 2048' {
[int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB).Value -ge 2048 [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB).Value -ge 2048
} }
# ── Step 3b: Windows KMS Activation ─────────────────────────────────────────
# Must run after network is confirmed (Step 3) and before Windows Update (Step 6)
# so activation completes before WU queries the license state.
# slmgr.vbs is a VBScript — invoke via cscript //NoLogo to get stdout output
# instead of a dialog box (dialogs are invisible / hang in WinRM sessions).
# Best-effort: activation failure does not abort Setup — CI builds work without
# activation (all features used here are available in the grace period).
Write-Step "Windows KMS activation"
$cscript = "$env:SystemRoot\System32\cscript.exe"
$slmgr = "$env:SystemRoot\System32\slmgr.vbs"
Write-Host "Setting KMS server..."
& $cscript //NoLogo $slmgr /skms kms8.msguides.com 2>&1 | Write-Host
Write-Host "Requesting activation..."
& $cscript //NoLogo $slmgr /ato 2>&1 | Write-Host
# Verify activation status (LicenseStatus 1 = Licensed)
$licOk = Get-CimInstance SoftwareLicensingProduct -Filter "Name like 'Windows%'" -ErrorAction SilentlyContinue |
Where-Object { $_.LicenseStatus -eq 1 }
if ($licOk) {
Write-Host " [OK] Windows activated (LicenseStatus=1)." -ForegroundColor Green
} else {
Write-Warning " [WARN] Windows not activated — /ato may have failed or KMS server unreachable. CI builds will work in the grace period."
}
# ── Step 4: Build user account ─────────────────────────────────────────────── # ── Step 4: Build user account ───────────────────────────────────────────────
Write-Step "Creating build user account: $BuildUsername" Write-Step "Creating build user account: $BuildUsername"
@@ -294,19 +344,13 @@ Assert-Step 'User' "user '$BuildUsername' member of Administrators" {
[bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername)
} }
# ── Step 4b: Disable UAC ───────────────────────────────────────────────────── # ── Step 4b: UAC (validation only — disabled by Deploy-WinBuild2025) ─────────
Write-Step "Disabling UAC (isolated lab VM)" Write-Step "UAC state (validation only — disabled at deploy time)"
# EnableLUA=0 disables UAC entirely — admin processes get full token without prompt. # Deploy-WinBuild2025.ps1 post-install.ps1 set EnableLUA=0 (no prompt for admin
# LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also # processes) and LocalAccountTokenFilterPolicy=1 (WinRM remote connections get full
# get an elevated token (avoids the filtered-token issue with runProgramInGuest). # elevated token, required for runProgramInGuest and remote admin commands).
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' `
-Name EnableLUA -Value 0 -Type DWord -Force
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' `
-Name LocalAccountTokenFilterPolicy -Value 1 -Type DWord -Force
Write-Host "UAC disabled."
# Validation
$uacPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $uacPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Assert-Step 'UAC' 'EnableLUA=0' { Assert-Step 'UAC' 'EnableLUA=0' {
(Get-ItemProperty -Path $uacPolicyKey -Name EnableLUA -ErrorAction Stop).EnableLUA -eq 0 (Get-ItemProperty -Path $uacPolicyKey -Name EnableLUA -ErrorAction Stop).EnableLUA -eq 0
@@ -335,109 +379,32 @@ foreach ($dir in $ciDirs) {
} }
} }
# ── Step 5b: Explorer settings ─────────────────────────────────────────────── # ── Step 5b: Explorer settings (validation only — configured by Deploy-WinBuild2025) ──
Write-Step "Configuring Explorer defaults" Write-Step "Explorer defaults (validation only — configured at deploy time)"
# Deploy-WinBuild2025.ps1 post-install.ps1 set LaunchTo=1 for HKCU (Administrator)
# and the Default User hive (so ci_build and any new user inherits at first logon).
# Open Explorer to 'This PC' (Devices and Drives) instead of Quick Access.
# LaunchTo=1 = This PC, LaunchTo=2 = Quick Access (default)
$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' $explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced'
Set-ItemProperty -Path $explorerAdvKey -Name 'LaunchTo' -Value 1 -Type DWord -Force
# Also apply to the Default User profile so ci_build and any new user inherits it.
$defaultHive = 'C:\Users\Default\NTUSER.DAT'
$mountName = 'TempDefaultUser'
if (Test-Path $defaultHive) {
reg load "HKU\$mountName" $defaultHive | Out-Null
$defKey = "Registry::HKU\$mountName\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced"
if (-not (Test-Path $defKey)) { New-Item -Path $defKey -Force | Out-Null }
Set-ItemProperty -Path $defKey -Name 'LaunchTo' -Value 1 -Type DWord -Force
[gc]::Collect()
reg unload "HKU\$mountName" | Out-Null
Write-Host "Explorer: 'This PC' set for current user and default profile."
}
else {
Write-Host "Explorer: 'This PC' set for current user (default hive not found)."
}
# Validation
Assert-Step 'Explorer' "LaunchTo=1 (HKCU current user)" { Assert-Step 'Explorer' "LaunchTo=1 (HKCU current user)" {
(Get-ItemProperty -Path $explorerAdvKey -Name LaunchTo -ErrorAction Stop).LaunchTo -eq 1 (Get-ItemProperty -Path $explorerAdvKey -Name LaunchTo -ErrorAction Stop).LaunchTo -eq 1
} }
# ── Step 5c: CI UX hardening ──────────────────────────────────────────────── # ── Step 5c: CI UX hardening (validation only — configured by Deploy-WinBuild2025)
Write-Step "CI UX hardening (lock screen off, Server Manager off, no CAD, no OOBE)" Write-Step "CI UX hardening state (validation only — configured at deploy time)"
# All UI tweaks batched here — fast registry writes before the slow WU step. # Deploy-WinBuild2025.ps1 post-install.ps1 set: lock screen (NoLockScreen=1), Server
# Manager (policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System +
# Winlogon), OOBE DisablePrivacyExperience (policy + non-policy), AllowTelemetry=0.
# 1 — Lock screen: disable entirely (CI VMs never need a lock screen)
# NoLockScreen=1 suppresses the lock screen shown before login on console sessions.
$personPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' $personPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization'
if (-not (Test-Path $personPolicyKey)) { New-Item -Path $personPolicyKey -Force | Out-Null } $smPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager'
Set-ItemProperty -Path $personPolicyKey -Name 'NoLockScreen' -Value 1 -Type DWord -Force $smHKLMKey = 'HKLM:\SOFTWARE\Microsoft\ServerManager'
Write-Host "Lock screen disabled." $smUserKey = 'HKCU:\SOFTWARE\Microsoft\ServerManager'
# 2 — Server Manager: do not auto-open at logon
# Three layers needed on WS2025:
# a) Machine policy key (GPO path — read by ServerManager policy check)
# b) HKLM non-policy key (read directly by ServerManager.exe on WS2025)
# c) Scheduled Task '\Microsoft\Windows\Server Manager\ServerManager'
# — this is the actual launcher; registry alone is insufficient on WS2025
# d) HKCU + Default hive for per-user belt-and-suspenders
$smPolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager'
if (-not (Test-Path $smPolicyKey)) { New-Item -Path $smPolicyKey -Force | Out-Null }
Set-ItemProperty -Path $smPolicyKey -Name 'DoNotOpenAtLogon' -Value 1 -Type DWord -Force
$smHKLMKey = 'HKLM:\SOFTWARE\Microsoft\ServerManager'
if (-not (Test-Path $smHKLMKey)) { New-Item -Path $smHKLMKey -Force | Out-Null }
Set-ItemProperty -Path $smHKLMKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force
Disable-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' `
-TaskName 'ServerManager' -ErrorAction SilentlyContinue | Out-Null
# Current session user (Administrator)
$smUserKey = 'HKCU:\SOFTWARE\Microsoft\ServerManager'
if (-not (Test-Path $smUserKey)) { New-Item -Path $smUserKey -Force | Out-Null }
Set-ItemProperty -Path $smUserKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force
# Default User hive — ci_build and any future account inherits at first logon
$smDefaultHive = 'C:\Users\Default\NTUSER.DAT'
$smMountName = 'TempDefaultUserSM'
if (Test-Path $smDefaultHive) {
reg load "HKU\$smMountName" $smDefaultHive | Out-Null
$smDefKey = "Registry::HKU\$smMountName\SOFTWARE\Microsoft\ServerManager"
if (-not (Test-Path $smDefKey)) { New-Item -Path $smDefKey -Force | Out-Null }
Set-ItemProperty -Path $smDefKey -Name 'DoNotOpenServerManagerAtLogon' -Value 1 -Type DWord -Force
[gc]::Collect()
reg unload "HKU\$smMountName" | Out-Null
Write-Host "Server Manager: policy + HKLM + task + Administrator HKCU + Default hive set."
}
else {
Write-Host "Server Manager: policy + HKLM + task + Administrator HKCU set (Default hive not found)."
}
# 3 — Disable Ctrl+Alt+Del at interactive login
# Set in both locations: Winlogon (classic) + Policies\System (policy, authoritative on WS2025).
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' `
-Name 'DisableCAD' -Value 1 -Type DWord -Force
$systemPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' $systemPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Set-ItemProperty -Path $systemPolicyKey -Name 'DisableCAD' -Value 1 -Type DWord -Force $oobePolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE'
Write-Host "Ctrl+Alt+Del at login disabled (Winlogon + Policies\System)." $dcKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection'
# 4 — Suppress OOBE privacy/telemetry prompt
$oobePolicyKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE'
if (-not (Test-Path $oobePolicyKey)) { New-Item -Path $oobePolicyKey -Force | Out-Null }
Set-ItemProperty -Path $oobePolicyKey -Name 'DisablePrivacyExperience' -Value 1 -Type DWord -Force
$oobeKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE'
if (-not (Test-Path $oobeKey)) { New-Item -Path $oobeKey -Force | Out-Null }
Set-ItemProperty -Path $oobeKey -Name 'DisablePrivacyExperience' -Value 1 -Type DWord -Force
$dcKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection'
if (-not (Test-Path $dcKey)) { New-Item -Path $dcKey -Force | Out-Null }
Set-ItemProperty -Path $dcKey -Name 'AllowTelemetry' -Value 0 -Type DWord -Force
Write-Host "OOBE privacy prompt and telemetry disabled."
# Validation
Assert-Step 'CIUX' 'lock screen disabled (NoLockScreen=1)' { Assert-Step 'CIUX' 'lock screen disabled (NoLockScreen=1)' {
(Get-ItemProperty -Path $personPolicyKey -Name NoLockScreen -ErrorAction Stop).NoLockScreen -eq 1 (Get-ItemProperty -Path $personPolicyKey -Name NoLockScreen -ErrorAction Stop).NoLockScreen -eq 1
} }
@@ -464,6 +431,25 @@ Assert-Step 'CIUX' 'OOBE policy DisablePrivacyExperience=1' {
Assert-Step 'CIUX' 'DataCollection AllowTelemetry=0' { Assert-Step 'CIUX' 'DataCollection AllowTelemetry=0' {
(Get-ItemProperty -Path $dcKey -Name AllowTelemetry -ErrorAction Stop).AllowTelemetry -eq 0 (Get-ItemProperty -Path $dcKey -Name AllowTelemetry -ErrorAction Stop).AllowTelemetry -eq 0
} }
# AutoAdminLogon: operativo — Deploy sets this, but Windows Server 2025 OOBE/first-boot
# tasks can reset AutoAdminLogon to 0 after the initial login. Re-affirm without touching
# DefaultPassword (already written by Deploy's post-install.ps1 and still present).
$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$wlNow = Get-ItemProperty -Path $wlKey -ErrorAction SilentlyContinue
if ($wlNow.AutoAdminLogon -ne '1' -or $wlNow.DefaultUserName -ne 'Administrator') {
Set-ItemProperty -Path $wlKey -Name AutoAdminLogon -Value '1' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultUserName -Value 'Administrator' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultDomainName -Value '.' -Type String -Force
}
# DefaultPassword written unconditionally when available — previous run may have set
# AutoAdminLogon=1 without knowing the password (AdminPassword was added later).
if ($AdminPassword -ne '') {
Set-ItemProperty -Path $wlKey -Name DefaultPassword -Value $AdminPassword -Type String -Force
}
Assert-Step 'CIUX' 'autologin Administrator enabled (AutoAdminLogon=1)' {
$wl = Get-ItemProperty -Path $wlKey -ErrorAction Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
# ── Step 6: Windows Update ──────────────────────────────────────────────────── # ── Step 6: Windows Update ────────────────────────────────────────────────────
# Firewall + Defender already off: WU downloads and installs run without scanning. # Firewall + Defender already off: WU downloads and installs run without scanning.
@@ -616,6 +602,12 @@ Write-Step "Disabling Windows Update permanently (post-update snapshot lockdown)
# Stop + disable main WU service # Stop + disable main WU service
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Set-Service -Name wuauserv -StartupType Disabled Set-Service -Name wuauserv -StartupType Disabled
# Write Start=4 directly to the service registry key in addition to the SCM call.
# Set-Service calls ChangeServiceConfig (SCM API) which WaaSMedicSvc can override via its
# own SCM call. The registry Start value requires WaaSMedicSvc to have registry write
# access — denied by the ACL block below.
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' `
-Name Start -Value 4 -Type DWord -Force
# Stop + disable Update Orchestrator Service (UsoSvc) — WU client in Server 2025 # Stop + disable Update Orchestrator Service (UsoSvc) — WU client in Server 2025
Stop-Service -Name UsoSvc -Force -ErrorAction SilentlyContinue Stop-Service -Name UsoSvc -Force -ErrorAction SilentlyContinue
@@ -646,6 +638,30 @@ foreach ($taskName in @('Scheduled Start', 'WakeTimer', 'Automatic App Update',
} }
Write-Host "Windows Update scheduled tasks disabled (best-effort)." Write-Host "Windows Update scheduled tasks disabled (best-effort)."
# Deny WaaSMedicSvc write access to the wuauserv service registry key so it cannot
# reset Start back to Manual (2) or Automatic (3) after we set it to Disabled (4).
# WaaSMedicSvc is a protected service that cannot itself be disabled; ACL denial
# is the only reliable way to prevent it from healing the service startup type.
# Best-effort: wrapped in try/catch so that a WinRM session permission failure is
# non-fatal (the GPO keys + Start=4 still provide a good baseline).
try {
$svcRegPath = 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv'
$acl = Get-Acl $svcRegPath
$medicSid = [System.Security.Principal.NTAccount]'NT SERVICE\WaaSMedicSvc'
$denyRule = New-Object System.Security.AccessControl.RegistryAccessRule(
$medicSid,
[System.Security.AccessControl.RegistryRights]'SetValue,CreateSubKey,WriteKey',
[System.Security.AccessControl.InheritanceFlags]::None,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Deny
)
$acl.AddAccessRule($denyRule)
Set-Acl $svcRegPath $acl
Write-Host "wuauserv: WaaSMedicSvc write-deny ACL applied."
} catch {
Write-Warning "wuauserv ACL deny skipped (non-fatal — GPO keys + Start=4 still in place): $_"
}
# Validation # Validation
Assert-Step 'WUDisable' 'wuauserv StartType Disabled' { Assert-Step 'WUDisable' 'wuauserv StartType Disabled' {
(Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled'
@@ -670,6 +686,8 @@ else {
$dotnetInstallScript = "$env:TEMP\dotnet-install.ps1" $dotnetInstallScript = "$env:TEMP\dotnet-install.ps1"
Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' ` Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' `
-OutFile $dotnetInstallScript -UseBasicParsing -OutFile $dotnetInstallScript -UseBasicParsing
Assert-Hash -FilePath $dotnetInstallScript -Label 'dotnet-install.ps1' `
-Expected $script:Hashes['DotNetInstallScript']
Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..." Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..."
& $dotnetInstallScript ` & $dotnetInstallScript `
@@ -716,6 +734,8 @@ else {
$pyInstallerPath = 'C:\CI\python_installer.exe' $pyInstallerPath = 'C:\CI\python_installer.exe'
Write-Host "Downloading Python $PythonVersion installer..." Write-Host "Downloading Python $PythonVersion installer..."
Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing
Assert-Hash -FilePath $pyInstallerPath -Label "python-$PythonVersion-amd64.exe" `
-Expected $script:Hashes['Python']
Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..." Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..."
$pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @( $pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @(
@@ -770,6 +790,8 @@ else {
Write-Host "Downloading VS Build Tools installer..." Write-Host "Downloading VS Build Tools installer..."
Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy
Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing
Assert-Hash -FilePath $vsInstallerPath -Label 'vs_buildtools.exe' `
-Expected $script:Hashes['VSBuildToolsBootstrapper']
# Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet). # Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet).
# SYSTEM account cannot execute them without this unblock step. # SYSTEM account cannot execute them without this unblock step.
@@ -1023,6 +1045,14 @@ Write-Host "DISM exited (code $($dism.ExitCode))."
Write-Host "Disk cleanup complete." Write-Host "Disk cleanup complete."
# DISM StartComponentCleanup / WaaSMedicSvc may reset service StartType during cleanup.
# Re-enforce Disabled on both WU services after DISM completes.
foreach ($svc in 'wuauserv', 'UsoSvc') {
Set-Service -Name $svc -StartupType Disabled -ErrorAction SilentlyContinue
}
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' `
-Name Start -Value 4 -Type DWord -Force -ErrorAction SilentlyContinue
# Validation — leftover artifacts from this run should be gone # Validation — leftover artifacts from this run should be gone
# Note: do NOT assert SoftwareDistribution\Download empty. # Note: do NOT assert SoftwareDistribution\Download empty.
# WaaSMedicSvc (Windows Update Medic Service) is tamper-protected — cannot be stopped — # WaaSMedicSvc (Windows Update Medic Service) is tamper-protected — cannot be stopped —
@@ -1030,14 +1060,39 @@ Write-Host "Disk cleanup complete."
# normal and harmless: WU is permanently disabled by Step 6b (service Disabled + GPO keys). # normal and harmless: WU is permanently disabled by Step 6b (service Disabled + GPO keys).
# Report remaining size as informational only. # Report remaining size as informational only.
$sdFiles = Get-ChildItem 'C:\Windows\SoftwareDistribution\Download' -Recurse -File -Force -ErrorAction SilentlyContinue $sdFiles = Get-ChildItem 'C:\Windows\SoftwareDistribution\Download' -Recurse -File -Force -ErrorAction SilentlyContinue
$sdMB = [math]::Round(($sdFiles | Measure-Object -Property Length -Sum).Sum / 1MB, 1) # Avoid Measure-Object .Sum: under Set-StrictMode -Version Latest, PS 5.1 treats the
Write-Host " SoftwareDistribution\Download: $($sdFiles.Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)." # nullable Sum property as absent when the pipe is empty, throwing "property not found".
$sdBytes = [long]0
if ($sdFiles) { foreach ($f in $sdFiles) { $sdBytes += $f.Length } }
$sdMB = [math]::Round($sdBytes / 1MB, 1)
Write-Host " SoftwareDistribution\Download: $(@($sdFiles).Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)."
Assert-Step 'Cleanup' 'wuauserv StartType still Disabled after cache clear' { Assert-Step 'Cleanup' 'wuauserv StartType Disabled after cache clear' {
(Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled'
} }
} # end if (-not $SkipCleanup) } # end if (-not $SkipCleanup)
# ── Pre-Final re-affirmation ──────────────────────────────────────────────────
# Steps 7-10 (installs, cleanup, DISM) can take hours. During that window background
# tasks or WaaSMedicSvc may reset AutoAdminLogon or wuauserv. Re-affirm both here
# immediately before the Final gate so the snapshot captures clean state.
$wlKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
$wlNow = Get-ItemProperty -Path $wlKey -ErrorAction SilentlyContinue
if ($wlNow.AutoAdminLogon -ne '1' -or $wlNow.DefaultUserName -ne 'Administrator') {
Set-ItemProperty -Path $wlKey -Name AutoAdminLogon -Value '1' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultUserName -Value 'Administrator' -Type String -Force
Set-ItemProperty -Path $wlKey -Name DefaultDomainName -Value '.' -Type String -Force
}
if ($AdminPassword -ne '') {
Set-ItemProperty -Path $wlKey -Name DefaultPassword -Value $AdminPassword -Type String -Force
}
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Set-Service -Name wuauserv -StartupType Disabled
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\wuauserv' `
-Name Start -Value 4 -Type DWord -Force
# ── Final pre-snapshot validation ──────────────────────────────────────────── # ── Final pre-snapshot validation ────────────────────────────────────────────
Write-Step "Final pre-snapshot validation" Write-Step "Final pre-snapshot validation"
@@ -1059,6 +1114,10 @@ Assert-Step 'Final' 'dotnet, python, msbuild all resolvable' {
(Test-Path 'C:\Python\python.exe' -PathType Leaf) -and (Test-Path 'C:\Python\python.exe' -PathType Leaf) -and
(Test-Path $msbuildPath -PathType Leaf) (Test-Path $msbuildPath -PathType Leaf)
} }
Assert-Step 'Final' 'AutoAdminLogon=1, DefaultUserName=Administrator' {
$wl = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
Assert-Step 'Final' 'Windows Update permanently disabled' { Assert-Step 'Final' 'Windows Update permanently disabled' {
(Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled' (Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled'
} }
+184
View File
@@ -0,0 +1,184 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Validates that Deploy-WinBuild2025.ps1 has correctly configured a guest VM.
.DESCRIPTION
Connects to the VM via WinRM (HTTP/Basic) and runs all checks that
Setup-WinBuild2025.ps1 Steps 1/2/3/4b/5b/5c assert as validation-only.
Use this after Deploy completes and before running Prepare/Setup.
.PARAMETER VMIPAddress
IP address of the guest VM on VMnet8 (e.g. 192.168.79.129).
.PARAMETER AdminUsername
Admin account inside the VM (default: Administrator).
.PARAMETER AdminPassword
Password for the admin account. Prompted if omitted.
.EXAMPLE
.\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $VMIPAddress,
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $AdminPassword) {
$secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString
$AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
}
$cred = New-Object System.Management.Automation.PSCredential(
$AdminUsername,
(ConvertTo-SecureString $AdminPassword -AsPlainText -Force)
)
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
try {
$cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) {
$newHosts = if ($cur) { "$cur,$VMIPAddress" } else { $VMIPAddress }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newHosts -Force
}
Write-Host "`nValidating Deploy state on $VMIPAddress..." -ForegroundColor Cyan
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
function Chk {
param([string]$Name, [scriptblock]$Test)
try { $ok = [bool](& $Test); $err = '' }
catch { $ok = $false; $err = $_.Exception.Message }
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
}
# ── Firewall ─────────────────────────────────────────────────────────
foreach ($p in Get-NetFirewallProfile) {
$n = $p.Name; $e = $p.Enabled
Chk "Firewall $n disabled" { $e -eq $false }
}
# ── Defender ─────────────────────────────────────────────────────────
Chk 'Defender GPO DisableAntiSpyware=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' `
-Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1
}
# ── WinRM ─────────────────────────────────────────────────────────────
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' {
(Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false'
}
Chk 'WinRM Auth/Basic=true' {
(Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true'
}
Chk 'WinRM MaxMemoryPerShellMB >= 2048' {
[int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048
}
# ── UAC ───────────────────────────────────────────────────────────────
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Chk 'UAC EnableLUA=0' {
(Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0
}
Chk 'UAC LocalAccountTokenFilterPolicy=1' {
(Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1
}
# ── Explorer ──────────────────────────────────────────────────────────
Chk 'Explorer LaunchTo=1 (HKCU)' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' `
-Name LaunchTo -EA Stop).LaunchTo -eq 1
}
# ── UX hardening ─────────────────────────────────────────────────────
Chk 'NoLockScreen=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' `
-Name NoLockScreen -EA Stop).NoLockScreen -eq 1
}
Chk 'Server Manager policy DoNotOpenAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' `
-Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1
}
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'Server Manager task Disabled' {
$t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' `
-TaskName 'ServerManager' -EA SilentlyContinue
(-not $t) -or ($t.State -eq 'Disabled')
}
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'DisableCAD=1 (Policies\System)' {
(Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1
}
Chk 'OOBE DisablePrivacyExperience=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' `
-Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1
}
Chk 'DataCollection AllowTelemetry=0' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' `
-Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0
}
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' {
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
# ── Windows Update GPO + services ─────────────────────────────────────
Chk 'WU GPO NoAutoUpdate=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `
-Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1
}
Chk 'WU GPO DisableWindowsUpdateAccess=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
-Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1
}
Chk 'wuauserv StartType=Manual' { (Get-Service wuauserv -EA Stop).StartType -eq 'Manual' }
Chk 'UsoSvc StartType=Manual' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Manual' }
return $results.ToArray()
}
Write-Host ''
$failed = 0
foreach ($r in $checks) {
if ($r.Pass) {
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
} else {
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
$failed++
}
}
Write-Host ''
if ($failed -eq 0) {
Write-Host "All $($checks.Count) Deploy checks passed." -ForegroundColor Green
exit 0
} else {
Write-Host "$failed / $($checks.Count) Deploy checks FAILED." -ForegroundColor Red
exit 1
}
} finally {
Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue
}
+371
View File
@@ -0,0 +1,371 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Validates full post-Setup state of a template VM (Deploy + Setup).
.DESCRIPTION
Connects to the VM via WinRM (HTTP/Basic) and runs all checks for the
final BaseClean snapshot state: Deploy OS hardening + Setup CI toolchain.
Run this after Prepare-WinBuild2025.ps1 completes (exit 0) to confirm
the VM is ready for snapshot and production use.
.PARAMETER VMIPAddress
IP address of the guest VM on VMnet8 (e.g. 192.168.79.129).
.PARAMETER AdminUsername
Admin account inside the VM (default: Administrator).
.PARAMETER AdminPassword
Password for the admin account. Prompted if omitted.
.PARAMETER BuildUsername
CI build account created by Setup (default: ci_build).
.PARAMETER DotNetChannel
Expected .NET SDK channel prefix (default: 10.0).
.PARAMETER PythonVersion
Expected exact Python version string (default: 3.13.3).
.PARAMETER Remediate
If set, automatically applies fixes for known remediable failures
(AutoAdminLogon, wuauserv/UsoSvc StartType=Disabled) then re-runs all checks.
.EXAMPLE
.\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129
.EXAMPLE
.\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129 -Remediate
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $VMIPAddress,
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword,
[string] $BuildUsername = 'ci_build',
[string] $DotNetChannel = '10.0',
[string] $PythonVersion = '3.13.3',
[switch] $Remediate
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $AdminPassword) {
$secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString
$AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
}
$cred = New-Object System.Management.Automation.PSCredential(
$AdminUsername,
(ConvertTo-SecureString $AdminPassword -AsPlainText -Force)
)
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
try {
$cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) {
$newHosts = if ($cur) { "$cur,$VMIPAddress" } else { $VMIPAddress }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newHosts -Force
}
Write-Host "`nValidating full Setup state on $VMIPAddress..." -ForegroundColor Cyan
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
param($BuildUsername, $DotNetChannel, $PythonVersion)
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
function Chk {
param([string]$Name, [scriptblock]$Test)
try { $ok = [bool](& $Test); $err = '' }
catch { $ok = $false; $err = $_.Exception.Message }
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
}
# ════════════════════════════════════════════════════════════════════
# DEPLOY checks (same as Validate-DeployState.ps1)
# ════════════════════════════════════════════════════════════════════
# Firewall
foreach ($p in Get-NetFirewallProfile) {
$n = $p.Name; $e = $p.Enabled
Chk "Firewall $n disabled" { $e -eq $false }
}
# Defender
Chk 'Defender GPO DisableAntiSpyware=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' `
-Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1
}
# WinRM
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' {
(Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false'
}
Chk 'WinRM Auth/Basic=true' {
(Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true'
}
Chk 'WinRM MaxMemoryPerShellMB >= 2048' {
[int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048
}
# UAC
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Chk 'UAC EnableLUA=0' {
(Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0
}
Chk 'UAC LocalAccountTokenFilterPolicy=1' {
(Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1
}
# Explorer
Chk 'Explorer LaunchTo=1 (HKCU)' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' `
-Name LaunchTo -EA Stop).LaunchTo -eq 1
}
# UX hardening
Chk 'NoLockScreen=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' `
-Name NoLockScreen -EA Stop).NoLockScreen -eq 1
}
Chk 'Server Manager policy DoNotOpenAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' `
-Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1
}
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'Server Manager task Disabled' {
$t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' `
-TaskName 'ServerManager' -EA SilentlyContinue
(-not $t) -or ($t.State -eq 'Disabled')
}
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'DisableCAD=1 (Policies\System)' {
(Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1
}
Chk 'OOBE DisablePrivacyExperience=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' `
-Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1
}
Chk 'DataCollection AllowTelemetry=0' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' `
-Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0
}
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' {
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
# ════════════════════════════════════════════════════════════════════
# SETUP checks (post-Prepare state)
# ════════════════════════════════════════════════════════════════════
# CI build user
Chk "user $BuildUsername exists" {
$null -ne (Get-LocalUser -Name $BuildUsername -EA Stop)
}
Chk "user $BuildUsername enabled" {
(Get-LocalUser -Name $BuildUsername -EA Stop).Enabled
}
Chk "user $BuildUsername PasswordNeverExpires" {
(Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null
}
Chk "user $BuildUsername member of Administrators" {
[bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername)
}
# CI directories
foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') {
Chk "$dir exists" { Test-Path $dir -PathType Container }
}
# Windows Update permanently disabled
Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' }
Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' }
Chk 'WU GPO NoAutoUpdate=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `
-Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1
}
Chk 'WU GPO DisableWindowsUpdateAccess=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
-Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1
}
# Toolchain
Chk ".NET SDK channel $DotNetChannel" {
$v = (& dotnet --version 2>&1) -as [string]
$v -and $v.StartsWith($DotNetChannel)
}
Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' }
Chk "Python version $PythonVersion" {
$v = (& 'C:\Python\python.exe' --version 2>&1) -as [string]
$v -and $v.Trim() -eq "Python $PythonVersion"
}
Chk 'MSBuild.exe present' {
$msb = Get-Command msbuild -ErrorAction SilentlyContinue
$msb -and (Test-Path $msb.Source)
}
# No leftover installer files in C:\CI root
Chk 'no leftover installer scripts in C:\CI' {
$leftovers = Get-ChildItem 'C:\CI' -File -EA SilentlyContinue |
Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' }
$leftovers.Count -eq 0
}
return $results.ToArray()
} -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion
Write-Host ''
$failed = 0
$deployCnt = 0
$setupCnt = 0
$inSetup = $false
foreach ($r in $checks) {
if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") {
Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan
$inSetup = $true
}
if ($r.Pass) {
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
} else {
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
$failed++
}
}
Write-Host ''
if ($failed -eq 0) {
Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green
exit 0
}
if (-not $Remediate) {
Write-Host "$failed / $($checks.Count) checks FAILED." -ForegroundColor Red
Write-Host "Re-run with -Remediate to auto-fix known issues." -ForegroundColor Yellow
exit 1
}
# ── Remediation pass ─────────────────────────────────────────────────────
$failedNames = @($checks | Where-Object { -not $_.Pass } | Select-Object -ExpandProperty Name)
Write-Host "Applying remediation for $($failedNames.Count) failed check(s)..." -ForegroundColor Yellow
Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
param([string[]] $FailedNames, [string] $AdminPassword)
if ($FailedNames -contains 'AutoAdminLogon=1, DefaultUserName=Administrator') {
$wl = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Set-ItemProperty $wl AutoAdminLogon '1' -Type String
Set-ItemProperty $wl DefaultUserName 'Administrator' -Type String
Set-ItemProperty $wl DefaultDomainName '.' -Type String
Set-ItemProperty $wl DefaultPassword $AdminPassword -Type String
Write-Host " [FIX] AutoAdminLogon keys written."
}
if ($FailedNames -contains 'wuauserv StartType=Disabled') {
Set-Service -Name wuauserv -StartupType Disabled -ErrorAction SilentlyContinue
Write-Host " [FIX] wuauserv set to Disabled."
}
if ($FailedNames -contains 'UsoSvc StartType=Disabled') {
Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue
Write-Host " [FIX] UsoSvc set to Disabled."
}
} -ArgumentList $failedNames, $AdminPassword
# ── Re-run checks ─────────────────────────────────────────────────────────
Write-Host "`nRe-validating after remediation..." -ForegroundColor Cyan
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
param($BuildUsername, $DotNetChannel, $PythonVersion)
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
function Chk {
param([string]$Name, [scriptblock]$Test)
try { $ok = [bool](& $Test); $err = '' }
catch { $ok = $false; $err = $_.Exception.Message }
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
}
foreach ($p in Get-NetFirewallProfile) { $n=$p.Name;$e=$p.Enabled; Chk "Firewall $n disabled" { $e -eq $false } }
Chk 'Defender GPO DisableAntiSpyware=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' -Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1 }
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' { (Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false' }
Chk 'WinRM Auth/Basic=true' { (Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true' }
Chk 'WinRM MaxMemoryPerShellMB >= 2048' { [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048 }
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Chk 'UAC EnableLUA=0' { (Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0 }
Chk 'UAC LocalAccountTokenFilterPolicy=1' { (Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1 }
Chk 'Explorer LaunchTo=1 (HKCU)' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' -Name LaunchTo -EA Stop).LaunchTo -eq 1 }
Chk 'NoLockScreen=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Name NoLockScreen -EA Stop).NoLockScreen -eq 1 }
Chk 'Server Manager policy DoNotOpenAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' -Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1 }
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 }
Chk 'Server Manager task Disabled' { $t=Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' -TaskName 'ServerManager' -EA SilentlyContinue; (-not $t) -or ($t.State -eq 'Disabled') }
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 }
Chk 'DisableCAD=1 (Policies\System)' { (Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1 }
Chk 'OOBE DisablePrivacyExperience=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' -Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1 }
Chk 'DataCollection AllowTelemetry=0' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' -Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0 }
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' { $wl=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop; $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' }
Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' }
Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' }
Chk 'WU GPO NoAutoUpdate=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1 }
Chk 'WU GPO DisableWindowsUpdateAccess=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' -Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1 }
Chk "user $BuildUsername exists" { $null -ne (Get-LocalUser -Name $BuildUsername -EA Stop) }
Chk "user $BuildUsername enabled" { (Get-LocalUser -Name $BuildUsername -EA Stop).Enabled }
Chk "user $BuildUsername PasswordNeverExpires" { (Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null }
Chk "user $BuildUsername member of Administrators" { [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) }
foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') { Chk "$dir exists" { Test-Path $dir -PathType Container } }
Chk ".NET SDK channel $DotNetChannel" { $v=(& dotnet --version 2>&1) -as [string]; $v -and $v.StartsWith($DotNetChannel) }
Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' }
Chk "Python version $PythonVersion" { $v=(& 'C:\Python\python.exe' --version 2>&1) -as [string]; $v -and $v.Trim() -eq "Python $PythonVersion" }
Chk 'MSBuild.exe present' { $msb=Get-Command msbuild -EA SilentlyContinue; $msb -and (Test-Path $msb.Source) }
Chk 'no leftover installer scripts in C:\CI' { $l=Get-ChildItem 'C:\CI' -File -EA SilentlyContinue | Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' }; $l.Count -eq 0 }
return $results.ToArray()
} -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion
Write-Host ''
$failed = 0
$inSetup = $false
foreach ($r in $checks) {
if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") {
Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan
$inSetup = $true
}
if ($r.Pass) {
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
} else {
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
$failed++
}
}
Write-Host ''
if ($failed -eq 0) {
Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green
exit 0
} else {
Write-Host "$failed / $($checks.Count) checks still FAILED after remediation." -ForegroundColor Red
exit 1
}
} finally {
Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue
}