Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4e619ddc2 | |||
| 8b54ca01b3 | |||
| d63611f78e | |||
| ee7f406f2a | |||
| edce871644 | |||
| 34b33c5011 | |||
| 68cde01c9d | |||
| 41611d46a7 | |||
| 7d6ae42fcf | |||
| 57e4a9713e | |||
| ad39aa5cf4 | |||
| 260e275b89 |
@@ -16,7 +16,7 @@
|
||||
- Gitea self-hosted — `http://10.10.20.11:3100`
|
||||
- act_runner v1.0.2 — Windows service su host, label `windows-build`
|
||||
- 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
|
||||
1. Gitea Actions enqueue job → act_runner lo prende
|
||||
|
||||
@@ -54,7 +54,7 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
|
||||
| .NET SDK | 10.0.203 |
|
||||
| Python | 3.13.3 |
|
||||
| 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
|
||||
|
||||
WinRM in modalità HTTP/Basic è **accettabile solo in ambiente lab isolato**.
|
||||
Per ambienti di produzione o condivisi, migrare a HTTPS/5986 con certificato self-signed
|
||||
e rimuovere `AllowUnencrypted=true`. Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md).
|
||||
WinRM usa **HTTPS/5986** con certificato self-signed e Basic auth.
|
||||
`-SkipCACheck`/`-SkipCNCheck` sono accettabili in lab isolato (il cert non è di una CA trusted).
|
||||
Per ambienti condivisi o di produzione, usare un certificato CA valida e rimuovere `-SkipCACheck`.
|
||||
Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md).
|
||||
|
||||
@@ -16,7 +16,35 @@
|
||||
> - **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
|
||||
|
||||
- [x] **Gitea Server** — già in esecuzione su `http://10.10.20.11:3100` (e `https://gitea.emulab.it`)
|
||||
@@ -65,7 +93,7 @@
|
||||
```powershell
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
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
|
||||
```
|
||||
- [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.
|
||||
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
|
||||
- Guest prep: `C:\CI` esiste, file copiato, size match
|
||||
- Post-setup remoto (9 check): WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs
|
||||
@@ -148,25 +176,22 @@
|
||||
|
||||
## 1. Sicurezza & hardening
|
||||
|
||||
### 1.1 [P0] Migrare WinRM da HTTP/Basic a HTTPS/5986
|
||||
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.
|
||||
### 1.1 [P0] [x] Migrare WinRM da HTTP/Basic a HTTPS/5986 — COMPLETATO 2026-05-10
|
||||
|
||||
**Azioni**:
|
||||
- [ ] Generare cert self-signed nel template *prima* dello snapshot.
|
||||
- [ ] Sostituire `New-PSSession -ComputerName $ip` con `-UseSSL -Port 5986` in `Invoke-RemoteBuild.ps1` e `Get-BuildArtifacts.ps1`.
|
||||
- [ ] Rimuovere `AllowUnencrypted=true` dal `Setup-WinBuild2025.ps1`.
|
||||
- [ ] Tenere `-SkipCACheck` lato host: per cert lab self-signed è accettabile, ma documentare la motivazione inline.
|
||||
- [ ] Regole firewall — limitare WinRM alla subnet build VM (`192.168.79.0/24` — VMnet8 NAT).
|
||||
- [x] Generare cert self-signed nel template *prima* dello snapshot (già in Deploy post-install.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`.
|
||||
- [x] Rimuovere `AllowUnencrypted=true` da Deploy post-install.ps1 e Setup-WinBuild2025.ps1 (assertion → false).
|
||||
- [x] `-SkipCACheck`/`-SkipCNCheck`/`-SkipRevocationCheck` mantenuti nei `New-PSSessionOption` e `New-WSManSessionOption` (cert lab self-signed — documentato inline).
|
||||
- [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:
|
||||
- `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
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
### 1.3 [P0] Pinning hash SHA256 degli installer
|
||||
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).
|
||||
### 1.3 [P0] [ ] Pinning hash SHA256 degli installer — STRUTTURA AGGIUNTA 2026-05-10 (valori da riempire)
|
||||
|
||||
**Motivazione**: Python, `dotnet-install.ps1`, `vs_buildtools.exe` vengono scaricati senza
|
||||
verifica integrità. Un MITM nella rete dell'host (anche temporaneo) può iniettare binari
|
||||
compromessi nel template, che poi viene snapshottato e usato da *ogni* build.
|
||||
`$script:Hashes` + `Assert-Hash` aggiunti a [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||
`Assert-Hash` viene chiamato dopo ogni download (dotnet-install.ps1, python installer, vs_buildtools.exe).
|
||||
Se l'hash è `''` viene emesso un warning ma l'esecuzione continua (non-breaking).
|
||||
|
||||
Pattern minimale:
|
||||
**Azione residua — OBBLIGATORIA prima del prossimo refresh snapshot**:
|
||||
- [ ] Scaricare i tre file, calcolare hash, riempire `$script:Hashes` in Setup-WinBuild2025.ps1:
|
||||
```powershell
|
||||
$expected = '6F25A7DF...' # SHA256 pinnato per la versione esatta
|
||||
$actual = (Get-FileHash $pyInstallerPath -Algorithm SHA256).Hash.ToLower()
|
||||
if ($actual -ne $expected.ToLower()) { throw "Hash mismatch: $actual" }
|
||||
# Inside or outside VM — qualsiasi PowerShell:
|
||||
(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']
|
||||
(Get-FileHash 'vs_buildtools.exe' -Algorithm SHA256).Hash # → $script:Hashes['VSBuildToolsBootstrapper']
|
||||
```
|
||||
Aggiungere un blocco hash per ogni download e bloccarli in costanti `$script:Hashes`.
|
||||
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
|
||||
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).
|
||||
### 1.4 [P1] [x] Validazione IP per-ottetto — COMPLETATO 2026-05-10
|
||||
|
||||
**Motivazione**: `'^(\d{1,3}\.){3}\d{1,3}$'` accetta `999.999.999.999`. Sostituire con regex
|
||||
per-ottetto:
|
||||
```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).
|
||||
Regex per-ottetto sostituita in tutti e 4 i file:
|
||||
`'^((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)$'`
|
||||
|
||||
### 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
|
||||
(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,
|
||||
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).
|
||||
|
||||
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 tracciamento.
|
||||
|
||||
### 1.7 [P2] Rotazione password guest VM
|
||||
### 1.7 [P2] [ ] Rotazione password guest VM
|
||||
- [ ] Trimestrale. Aggiornare credenziale `BuildVMGuest` in Credential Manager + password
|
||||
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
|
||||
lo usano (post-migrazione HTTPS).
|
||||
|
||||
@@ -245,7 +266,7 @@ senza tracciamento.
|
||||
|
||||
## 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).
|
||||
|
||||
**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).
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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,
|
||||
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).
|
||||
|
||||
Aggiungere `[CmdletBinding(SupportsShouldProcess)]` come fatto in `Cleanup-OrphanedBuildVMs.ps1`.
|
||||
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).
|
||||
|
||||
**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).
|
||||
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.
|
||||
|
||||
Pre-snapshot: copia atomica del `parent` VMDK in `F:\CI\Backups\Template_<date>\`. Su errore
|
||||
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.
|
||||
|
||||
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.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).
|
||||
|
||||
**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.
|
||||
|
||||
### 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`).
|
||||
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`.
|
||||
|
||||
### 3.3 [P2] In-VM clone (`-UseGitClone`)
|
||||
### 3.3 [P2] [ ] In-VM clone (`-UseGitClone`)
|
||||
Implementazione disegnata di seguito (sezione "In-VM Git Clone (Ottimizzazione)").
|
||||
Vincoli sicurezza PAT: vedi §1.5. Tool prerequisiti (Git, 7-Zip): vedi §6.6.
|
||||
Beneficio reale solo per repo > 200 MB con submoduli grandi; misurare prima.
|
||||
|
||||
### 3.4 [P3] Pre-warm pool di cloni
|
||||
### 3.4 [P3] [ ] Pre-warm pool di cloni
|
||||
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.
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
`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.
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
**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
|
||||
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:
|
||||
```
|
||||
ci_runner_orphan_vms 0
|
||||
@@ -434,13 +455,13 @@ ci_runner_active_jobs 1
|
||||
Se l'homelab ha già Prometheus + node_exporter, basta
|
||||
`--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`.
|
||||
|
||||
`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.
|
||||
|
||||
### 4.4 [P2] Runbook per incident comuni
|
||||
### 4.4 [P2] [ ] Runbook per incident comuni
|
||||
File: nuovo `docs/RUNBOOK.md`.
|
||||
|
||||
Documentare con copy-pasta:
|
||||
@@ -451,7 +472,7 @@ Documentare con copy-pasta:
|
||||
|
||||
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`)
|
||||
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.
|
||||
@@ -460,7 +481,7 @@ file:// — utile per debug a colpo d'occhio senza aprire Gitea UI.
|
||||
|
||||
## 5. Qualità codice & test
|
||||
|
||||
### 5.1 [P1] Pester smoke tests sugli script
|
||||
### 5.1 [P1] [ ] Pester smoke tests sugli script
|
||||
File: nuovo `tests/` directory.
|
||||
|
||||
**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).
|
||||
|
||||
### 5.2 [P2] Modulo PowerShell condiviso `scripts/_Common.psm1`
|
||||
### 5.2 [P2] [ ] Modulo PowerShell condiviso `scripts/_Common.psm1`
|
||||
**Duplicazioni da estrarre**:
|
||||
- `New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck` (3 file).
|
||||
- `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.
|
||||
|
||||
### 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`:
|
||||
```powershell
|
||||
$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
|
||||
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).
|
||||
|
||||
Aggiungere regole specifiche progetto (`PSScriptAnalyzerSettings.psd1` in root):
|
||||
@@ -502,7 +523,7 @@ Aggiungere regole specifiche progetto (`PSScriptAnalyzerSettings.psd1` in root):
|
||||
- `PSUseShouldProcessForStateChangingFunctions`.
|
||||
- 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
|
||||
validazione runtime.
|
||||
|
||||
@@ -510,7 +531,7 @@ validazione runtime.
|
||||
|
||||
## 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)
|
||||
— 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
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
stesso `Setup-Host.ps1` registrato come secondo runner Gitea (label `windows-build:host-2`).
|
||||
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
|
||||
(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).
|
||||
|
||||
Attualmente single job. Quando arriverà la VM Linux:
|
||||
@@ -557,11 +578,11 @@ strategy:
|
||||
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
|
||||
`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).
|
||||
|
||||
**Motivazione**: toolchain attuale (`.NET SDK`, `Python`, `VS Build Tools`) copre solo C#/Python/MSBuild.
|
||||
@@ -571,7 +592,7 @@ build più ampi senza toccare il template.
|
||||
Tool da aggiungere come step `Setup-WinBuild2025` (ognuno con `Assert-Step`):
|
||||
|
||||
| Tool | Versione | Razionale |
|
||||
| ------------------- | ----------- | ------------------------------------------------------------ |
|
||||
| ------------------- | ----------------------- | -------------------------------------------------------------- |
|
||||
| **Git for Windows** | latest LTS | Self-clone in VM (alt a host-zip-transfer), submodule fetch |
|
||||
| **7-Zip** | latest | Universale unpack/zip; molti installer e workflow ne dipendono |
|
||||
| **PowerShell 7.x** | latest LTS | Più veloce di 5.1, parallel pipelines, operatori moderni |
|
||||
@@ -619,90 +640,56 @@ duplicazioni; ogni concern ha **una sola** sorgente di verità.
|
||||
**Stato attuale (duplicati)**: UAC, Explorer LaunchTo, Server Manager off, DisableCAD,
|
||||
OOBE telemetry, WU services disable — entrambi gli script li toccano.
|
||||
|
||||
### 7.1 [P1] Spostare base OS hardening da Setup a Deploy
|
||||
### 7.1 [P1] [x] Spostare base OS hardening da Setup a Deploy
|
||||
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
|
||||
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||
|
||||
**Da MUOVERE Setup → Deploy**:
|
||||
- [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 |
|
||||
| ----------------------------- | ------------------ | ------------------------------------------- |
|
||||
| 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)
|
||||
### 7.2 [P1] [x] WU lifecycle — strategia B (Deploy: GPO + Manual; Setup: lifecycle)
|
||||
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
|
||||
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||
|
||||
**Stato attuale (conflitto ordering)**:
|
||||
- Deploy: `sc.exe config wuauserv start= disabled` + `sc.exe stop wuauserv` (idem UsoSvc, WaaSMedicSvc) + GPO `NoAutoUpdate=1`
|
||||
- Setup Step 6: necessita servizi UP per SchTask SYSTEM run WU
|
||||
- Setup Step 6b: re-disabilita servizi → doppio lavoro, fragile
|
||||
Strategia B: Deploy lascia servizi WU `start=demand` (Manual, default Windows) e scrive solo
|
||||
GPO `NoAutoUpdate=1` + `DisableWindowsUpdateAccess=1`. Setup Step 6 clears i GPO locks (Step A),
|
||||
avvia WU via SchTask SYSTEM, poi Step 6b disabilita servizi permanentemente post-update.
|
||||
|
||||
**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):
|
||||
- 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
|
||||
### 7.3 [P2] [x] Update header docs di entrambi gli script
|
||||
|
||||
**Setup** (Step 6 / 6b):
|
||||
- Step 6 (skip se `-SkipWindowsUpdate`): `sc.exe config wuauserv start= demand` (idempotent), avvia SchTask SYSTEM, attende exit code, parse 0/2/3/3010
|
||||
- Step 6b (sempre, anche con `-Skip`): `sc.exe config wuauserv start= disabled` + `sc.exe stop wuauserv` (idem UsoSvc, WaaSMedicSvc), conferma GPO presenti — snapshot capture WU permanently off
|
||||
- [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)
|
||||
- [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
|
||||
- [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):
|
||||
- Re-deploy template `BaseClean_<yyyyMMdd>` ogni N mesi via Deploy + Prepare (no `-Skip`) → patch applicate al template fresh
|
||||
### 7.4 [P2] [x] Test e2e refactor — COMPLETATO 2026-05-10
|
||||
|
||||
**Validazione finale** (Setup Final gate):
|
||||
- `wuauserv.StartType == Disabled`
|
||||
- `UsoSvc.StartType == Disabled`
|
||||
- GPO `NoAutoUpdate=1`, `DisableWindowsUpdateAccess=1`
|
||||
- [x] Deploy stand-alone su VM test → `Get-NetFirewallProfile` tutti `Enabled=False`, `Get-Service wuauserv | Select StartType` → `Manual`, `NoAutoUpdate=1` GPO presente
|
||||
- [x] Prepare dopo Deploy con `-SkipWindowsUpdate` → tutti `Assert-Step` passano `[OK]` senza eseguire Set (log mostra solo validation output)
|
||||
- [x] Prepare senza `-SkipWindowsUpdate` → WU (0 update trovati, ResultCode=0), Step 6b disabilita wuauserv/UsoSvc (`StartType=Disabled`)
|
||||
- [x] Final gate del guest passa su tutti e 7 i check; snapshot `BaseClean` preso
|
||||
|
||||
**Test acceptance**:
|
||||
- Deploy stand-alone: `Get-Service wuauserv | Select StartType` → `Manual`, `Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate` → 1
|
||||
- Setup `-SkipWindowsUpdate`: Final gate vede `StartType=Disabled` (impostato da Step 6b)
|
||||
- Setup senza `-Skip`: WU runs, exit 0/2/3, poi servizi disabled
|
||||
**Fix emersi durante §7.4** (applicati):
|
||||
- Deploy: `UsoSvc` impostato a `Manual` esplicitamente (default Server 2025 = Automatic)
|
||||
- Setup cleanup: `Set-Service -StartupType Disabled` ri-applicato su wuauserv/UsoSvc dopo DISM `StartComponentCleanup` (DISM/WaaSMedicSvc può resettare StartType)
|
||||
- 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
|
||||
- [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)
|
||||
### 7.5 [P3] [ ] Rinomina Setup → Setup-CITools (opzionale, futuro)
|
||||
Una volta che Setup fa solo build customization, nome `Setup-WinBuild2025` è impreciso —
|
||||
non setup-a la build VM, **estende** un template OS già pronto con toolchain CI.
|
||||
Rinominare in `Setup-CITools.ps1` o `Install-CIToolchain.ps1` rifletterebbe meglio la
|
||||
|
||||
@@ -53,7 +53,7 @@ The runner host **never executes build tools directly**. Its only role is orches
|
||||
│ │ ├── Clone_Job_002.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
|
||||
- Default transport for remote build execution inside VMs
|
||||
- Port: **5985** (HTTP, in-lab use); migrate to 5986/HTTPS for hardened setups
|
||||
- Authentication: **Basic** (unencrypted — acceptable for isolated lab)
|
||||
- Port: **5986** (HTTPS, self-signed cert, `AllowUnencrypted=false`)
|
||||
- Authentication: **Basic over HTTPS** (`-SkipCACheck` for lab self-signed cert)
|
||||
- Used for:
|
||||
- Transferring source: `Compress-Archive` on host → WinRM copy → `Expand-Archive` in guest
|
||||
- Running build commands (`Invoke-Command`)
|
||||
@@ -133,7 +133,7 @@ Build VMs:
|
||||
...
|
||||
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.
|
||||
```
|
||||
|
||||
|
||||
+12
-33
@@ -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:
|
||||
- Build VMs sono su VMnet8 NAT (192.168.79.0/24) — raggiungibili dall'host, internet via NAT
|
||||
- No external traffic reaches port 5985
|
||||
- Credentials are managed via Windows Credential Manager
|
||||
`Deploy-WinBuild2025.ps1` post-install.ps1 crea un certificato self-signed e configura
|
||||
il listener HTTPS/5986 **prima** dello snapshot `BaseClean`. `AllowUnencrypted=false`.
|
||||
|
||||
### 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
|
||||
# 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
|
||||
$session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL `
|
||||
$session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL -Authentication Basic `
|
||||
-Credential $cred -SessionOption $sessionOptions
|
||||
```
|
||||
|
||||
> `-SkipCACheck` is acceptable for a self-signed cert in an isolated lab. Do NOT
|
||||
> use this against externally accessible machines.
|
||||
> `-SkipCACheck`/`-SkipCNCheck` sono accettabili per un cert self-signed in lab isolato.
|
||||
> 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:
|
||||
- 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)
|
||||
- Gitea server if on LAN reachable via NAT gateway
|
||||
|
||||
|
||||
+2
-2
@@ -120,10 +120,10 @@ Allo `Setup-Host.ps1` exit 0:
|
||||
|
||||
3. **Configura host WinRM client** (necessario per Prepare-WinBuild2025.ps1):
|
||||
```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
|
||||
```
|
||||
*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)
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ Equivalente di `Prepare-WinBuild2025.ps1` per Linux. Usa SSH invece di WinRM.
|
||||
|
||||
| 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 |
|
||||
| Trasferimento file | `Copy-Item -ToSession` | `scp` o `rsync` |
|
||||
| Esecuzione remota | `Invoke-Command -Session` | `ssh user@host 'cmd'` |
|
||||
|
||||
@@ -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: 30–90 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` né `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`
|
||||
@@ -9,12 +9,19 @@ credenziali archiviate, ISO Windows Server presente).
|
||||
|
||||
---
|
||||
|
||||
## Architettura: due script
|
||||
## Architettura: tre script
|
||||
|
||||
| Script | Dove gira | Scopo |
|
||||
| ---------------------------------------- | --------------- | -------------------------------------------------- |
|
||||
| `template/Prepare-WinBuild2025.ps1` | **Host** | Orchestratore: copia + esegue lo script in VM |
|
||||
| `template/Setup-WinBuild2025.ps1` | **Dentro VM** | Provisioning effettivo (firewall, defender, tool) |
|
||||
| ---------------------------------------- | --------------- | ------------------------------------------------------------------- |
|
||||
| `template/Deploy-WinBuild2025.ps1` | **Host** | Opzionale: crea VM e installa Windows unattended da ISO |
|
||||
| `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
|
||||
`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) |
|
||||
| Disco | 80 GB thin provisioned |
|
||||
| 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\`) |
|
||||
|
||||
---
|
||||
@@ -41,7 +48,7 @@ credenziali archiviate, ISO Windows Server presente).
|
||||
- File → New Virtual Machine → Custom
|
||||
- Hardware: 4 vCPU, 6144 MB, 80 GB thin
|
||||
- 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
|
||||
|
||||
### A.2 Installa Windows Server 2025
|
||||
@@ -58,7 +65,8 @@ winrm quickconfig -q
|
||||
```powershell
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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):
|
||||
|
||||
1. **Pre-flight**: script dir presente, `Setup-WinBuild2025.ps1` presente, IP ottetti 0-255, TCP/5985 reachable
|
||||
2. **Configura host WinRM client**: `AllowUnencrypted=true`, aggiunge `$VMIPAddress` ai `TrustedHosts`
|
||||
- Salva valori precedenti, ripristinati nel `finally` (host posture invariata)
|
||||
3. **Validazione host WinRM**: `AllowUnencrypted=true`, `TrustedHosts` include IP
|
||||
4. **Test connettività**: `Test-WSMan -Authentication Basic` con credenziali admin
|
||||
5. **Apre PSSession** verso la VM
|
||||
1. **Pre-flight**: script dir presente, `Setup-WinBuild2025.ps1` presente, IP ottetti 0-255, TCP/5986 reachable
|
||||
2. **Configura host WinRM client**: aggiunge `$VMIPAddress` ai `TrustedHosts` (no `AllowUnencrypted` — HTTPS non lo richiede)
|
||||
- Salva valore precedente, ripristinato nel `finally` (host posture invariata)
|
||||
3. **Validazione host WinRM**: `TrustedHosts` include IP
|
||||
4. **Test connettività**: `Test-WSMan -Port 5986 -UseSSL -Authentication Basic` con credenziali admin
|
||||
5. **Apre PSSession** `-UseSSL -Port 5986` verso la VM
|
||||
6. **Crea `C:\CI`** in guest, valida creazione
|
||||
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)
|
||||
@@ -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)
|
||||
|
||||
Eseguito automaticamente da Prepare in Fase B. Ordine ottimizzato per ridurre overhead:
|
||||
tutti i tweak UI/registro sono raggruppati **prima** di Windows Update (operazione lenta).
|
||||
Ogni step ha validazione `Assert-Step`.
|
||||
Eseguito automaticamente da Prepare in Fase B. Steps 1/2/3/4b/5b/5c sono
|
||||
**validation-only** — verificano che Deploy abbia già applicato l'hardening OS.
|
||||
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 2 Defender + Antimalware off (Set-MpPreference + GPO)
|
||||
── prima di WinRM e WU
|
||||
Step 3 WinRM hardening (Basic, AllowUnencrypted, 2 GB shell mem, 2h timeout)
|
||||
Step 4 Build user account (ci_build, admin, no expire) ── prima di WU
|
||||
Step 4b UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1)
|
||||
Step 1 Firewall: validation only — Deploy disabled all profiles [Assert-Step]
|
||||
Step 2 Defender: validation only — Deploy set DisableAntiSpyware GPO + RTP off [Assert-Step]
|
||||
Step 3 WinRM: validation only — Deploy set Basic/Unencrypted/MaxMem/Timeout [Assert-Step]
|
||||
Step 4 Build user account (ci_build, admin, no expire) ── crea utente CI
|
||||
Step 4b UAC: validation only — Deploy set EnableLUA=0 + TokenFilterPolicy [Assert-Step]
|
||||
Step 5 CI dirs: C:\CI\{build, output, scripts} ── C:\CI deve esistere prima di WU
|
||||
Step 5b Explorer LaunchTo=1 (This PC)
|
||||
Step 5c CI UX hardening: Server Manager off, DisableCAD, OOBE/telemetry off
|
||||
Step 6 Windows Update via Scheduled Task SYSTEM ── senza scan Defender, C:\CI presente
|
||||
Step 5b Explorer LaunchTo=1: validation only — Deploy set HKCU + Default hive [Assert-Step]
|
||||
Step 5c CI UX hardening: validation only — Deploy set lock screen, SM policy/task,
|
||||
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 7 .NET SDK install (channel via dotnet-install.ps1)
|
||||
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
|
||||
|
||||
- **Firewall first** — Windows blocca alcune chiamate WinRM finché non è classificato il
|
||||
profilo di rete. Off subito → niente race condition durante Step 3.
|
||||
- **Defender second** — disabilitato prima di WinRM e WU. WU scarica/installa update
|
||||
senza scan (5-15 min risparmiati). Step 7-9 (download + install ~200-400 MB di
|
||||
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.
|
||||
- **Steps 1-3 (validazioni)** — verificano prima le precondizioni Deploy (firewall off,
|
||||
Defender off, WinRM pronto) in modo che i failure emergano subito, non a metà toolchain.
|
||||
- **User + UAC (4/4b)** — crea `ci_build`; valida UAC off (set da Deploy); UAC off prima
|
||||
di dir/tool evita prompt di elevazione negli step 7-9.
|
||||
- **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.
|
||||
- **UI tweaks (5b/5c)** — tutti i tweak registro raggruppati prima del WU (lento);
|
||||
scritture veloci, nessuna dipendenza da WU o toolchain.
|
||||
- **WU sixth** — gira con tutto già off + `C:\CI` presente; condizioni ottimali.
|
||||
- **WU disable subito dopo** — snapshot cattura WU permanentemente off.
|
||||
- **UI tweaks (5b/5c)** — validazioni registro rapide; raggruppate prima di WU (lento).
|
||||
- **WU (Step 6)** — clears i GPO lock di Deploy (Step A), avvia COM API via SYSTEM task,
|
||||
poi Step 6b re-disabilita tutto → snapshot cattura WU permanentemente off.
|
||||
- **Toolchain (7-9) last** — WU completato, nessun scan su payload installer.
|
||||
|
||||
### Validazioni per step (sintesi)
|
||||
|
||||
| Step | Check chiave |
|
||||
| ---- | ------------------------------------------------------------------------------- |
|
||||
| 1 | `Get-NetFirewallProfile -Profile Domain/Private/Public → Enabled=False` |
|
||||
| 2 | 6 GPO key + `Set-MpPreference DisableRealtimeMonitoring=true` + ExclusionPath |
|
||||
| 3 | WinRM Running+Automatic, AllowUnencrypted=true, Auth/Basic=true, MaxMem≥2048 |
|
||||
| 4 | User exists, enabled, PasswordNeverExpires, member of Administrators |
|
||||
| 4b | EnableLUA=0, LocalAccountTokenFilterPolicy=1 |
|
||||
| 5 | Ogni dir Test-Path -PathType Container |
|
||||
| 5b | HKCU LaunchTo=1 |
|
||||
| 5c | ServerManager machine policy DoNotOpenAtLogon=1, HKCU DoNotOpenServerManagerAtLogon=1, DisableCAD=1, OOBE DisablePrivacyExperience=1, AllowTelemetry=0 |
|
||||
| 6 | ResultCode ∈ {0,2,3}, no reboot required (else exit 3010) |
|
||||
| 6b | wuauserv StartType=Disabled, NoAutoUpdate=1, DisableWindowsUpdateAccess=1 |
|
||||
| 7 | `dotnet --version` channel match, machine PATH contiene `C:\dotnet` |
|
||||
| 8 | `C:\Python\python.exe` esiste, version match esatto, PATH contiene `C:\Python` |
|
||||
| 9 | MSBuild.exe esiste, esegue, PATH, `\VC\` dir presente |
|
||||
| 10 | dotnet+python+msbuild tutti risolvibili (throw, non più warn) |
|
||||
| Final| WinRM, firewall, user+admin, UAC, WU disabled, tutti tool, no leftover installer in `C:\CI` |
|
||||
| Step | Natura | Check chiave |
|
||||
| ---- | ---------- | ------------------------------------------------------------------------------ |
|
||||
| 1 | Assert | `Get-NetFirewallProfile -Profile Domain/Private/Public → Enabled=False` |
|
||||
| 2 | Assert | GPO DisableAntiSpyware=1 (set da Deploy) |
|
||||
| 3 | Assert | WinRM Running+Automatic, AllowUnencrypted=false (HTTPS-only), Auth/Basic=true, MaxMem≥2048 |
|
||||
| 4 | Operativo | User exists, enabled, PasswordNeverExpires, member of Administrators |
|
||||
| 4b | Assert | EnableLUA=0, LocalAccountTokenFilterPolicy=1 |
|
||||
| 5 | Operativo | Ogni dir Test-Path -PathType Container |
|
||||
| 5b | Assert | HKCU LaunchTo=1 |
|
||||
| 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 | Operativo | ResultCode ∈ {0,2,3}, no reboot required (else exit 3010) |
|
||||
| 6b | Operativo | wuauserv StartType=Disabled, NoAutoUpdate=1, DisableWindowsUpdateAccess=1 |
|
||||
| 7 | Operativo | `dotnet --version` channel match, machine PATH contiene `C:\dotnet` |
|
||||
| 8 | Operativo | `C:\Python\python.exe` esiste, version match esatto, PATH contiene `C:\Python` |
|
||||
| 9 | Operativo | MSBuild.exe esiste, esegue, PATH, `\VC\` dir presente |
|
||||
| 10 | Operativo | dotnet+python+msbuild tutti risolvibili (throw) |
|
||||
| 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
|
||||
|
||||
Dall'host:
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
@@ -70,6 +70,9 @@ $session = New-PSSession `
|
||||
-ComputerName $IPAddress `
|
||||
-Credential $Credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-UseSSL `
|
||||
-Port 5986 `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
|
||||
try {
|
||||
|
||||
@@ -103,7 +103,7 @@ param(
|
||||
|
||||
[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] $GuestCredentialTarget = 'BuildVMGuest',
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
@@ -88,6 +88,9 @@ $session = New-PSSession `
|
||||
-ComputerName $IPAddress `
|
||||
-Credential $Credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-UseSSL `
|
||||
-Port 5986 `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,7 @@ param(
|
||||
[string] $VMPath,
|
||||
|
||||
[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,
|
||||
|
||||
[ValidateRange(30, 600)]
|
||||
@@ -52,7 +52,7 @@ param(
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
||||
|
||||
# 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.
|
||||
[switch] $SkipPing
|
||||
)
|
||||
@@ -107,17 +107,20 @@ while ((Get-Date) -lt $deadline) {
|
||||
}
|
||||
}
|
||||
|
||||
# ── Phase 3: WinRM responsive ────────────────────────────────────────
|
||||
try {
|
||||
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
|
||||
# Success — VM is ready
|
||||
# ── Phase 3: WinRM port 5986 accepting TCP connections ───────────────
|
||||
# Test-WSMan has no -SessionOption in PS 5.1 and fails cert validation for
|
||||
# self-signed certs. TCP open on 5986 = HTTPS listener up = VM 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)."
|
||||
return
|
||||
}
|
||||
catch {
|
||||
else {
|
||||
$phase = 'winrm'
|
||||
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
|
||||
}
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,11 @@
|
||||
via IMAPI2FS COM (built-in Windows, zero deps)
|
||||
Step 4 — Create the VMDK (80 GB single-file thin) via vmware-vdiskmanager
|
||||
Step 5 — Generate the .vmx (UEFI no SecureBoot, NVMe disk, e1000e NIC,
|
||||
three CD-ROMs: Win install + autounattend + VMware Tools)
|
||||
Step 6 — Power on the VM (vmrun start nogui)
|
||||
two CD-ROMs: Win install + autounattend; Tools bundled inside autounattend)
|
||||
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
|
||||
(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 10 — Take snapshot named $SnapshotName
|
||||
Final — Print summary (VM path, IP, snapshot name)
|
||||
@@ -36,13 +36,22 @@
|
||||
|
||||
Hardening done by post-install.ps1 inside the guest:
|
||||
- UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1)
|
||||
- Defender real-time protection off
|
||||
- Defender real-time protection off + DisableAntiSpyware=1 GPO
|
||||
- DiagTrack/Telemetry off
|
||||
- Windows Update services disabled
|
||||
- RDP enabled (no NLA, lab only) + firewall rule
|
||||
- WinRM enabled HTTP 5985 + HTTPS 5986 (self-signed cert), Basic auth,
|
||||
AllowUnencrypted, TrustedHosts=*
|
||||
- Windows Update GPO locks (NoAutoUpdate=1, DisableWindowsUpdateAccess=1);
|
||||
services remain Manual — Setup-WinBuild2025 Step 6b permanently disables
|
||||
them after applying updates (§7.2 Strategy B)
|
||||
- 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
|
||||
- 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
|
||||
- install_complete.flag dropped in C:\Windows\Temp
|
||||
|
||||
@@ -50,7 +59,8 @@
|
||||
Path to the Windows Server 2025 install ISO.
|
||||
|
||||
.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
|
||||
Full path of the .vmx file to create. The folder is created if missing.
|
||||
@@ -117,7 +127,7 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# ── 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',
|
||||
|
||||
# Cache path for the rebuilt no-prompt Windows ISO. Reused across runs when
|
||||
@@ -138,7 +148,7 @@ param(
|
||||
# ── Hardware ──
|
||||
[int] $DiskSizeGB = 80,
|
||||
[int] $MemoryMB = 6144,
|
||||
[int] $VCPUCount = 2,
|
||||
[int] $VCPUCount = 4,
|
||||
[int] $CoresPerSocket = 2,
|
||||
|
||||
# ── Locale ──
|
||||
@@ -159,7 +169,14 @@ param(
|
||||
|
||||
# When set, vmrun powers on the VM with the Workstation GUI visible.
|
||||
# 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' }
|
||||
@@ -172,9 +189,14 @@ $ErrorActionPreference = 'Stop'
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
param([string] $Message, [int] $Step = 0)
|
||||
$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 {
|
||||
param(
|
||||
@@ -344,8 +366,14 @@ function New-WinIsoNoPrompt {
|
||||
|
||||
function Invoke-Vmrun {
|
||||
param([Parameter(Mandatory)] [string[]] $Arguments,
|
||||
[switch] $IgnoreErrors)
|
||||
[switch] $IgnoreErrors,
|
||||
[switch] $Async)
|
||||
$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
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ne 0 -and -not $IgnoreErrors) {
|
||||
@@ -371,15 +399,80 @@ function Set-VmxKey {
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step 'Step 1: pre-flight validation'
|
||||
|
||||
# ── Derived paths — always computed regardless of -StartFromStep ─────────────
|
||||
$vmrunPath = Join-Path $VMwareWorkstationDir 'vmrun.exe'
|
||||
$vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe'
|
||||
$scriptRoot = Split-Path -Parent $PSCommandPath
|
||||
$xmlTemplate = Join-Path $scriptRoot 'autounattend.template.xml'
|
||||
$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' "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)" {
|
||||
$ComputerName -match '^[A-Za-z0-9-]{1,15}$'
|
||||
}
|
||||
|
||||
$vmDir = Split-Path -Parent $VMXPath
|
||||
if (-not (Test-Path $vmDir)) {
|
||||
New-Item -ItemType Directory -Path $vmDir -Force | Out-Null
|
||||
}
|
||||
Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir }
|
||||
|
||||
if (Test-Path $VMXPath) {
|
||||
throw "[PreFlight] $VMXPath already exists. Remove the existing VM folder before re-running."
|
||||
}
|
||||
|
||||
$vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk'
|
||||
$vmdkPath = Join-Path $vmDir $vmdkName
|
||||
$autounattendIso = Join-Path $vmDir 'autounattend.iso'
|
||||
$stagingDir = Join-Path $vmDir '_autounattend_staging'
|
||||
} # end Step 1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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 }
|
||||
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
|
||||
L 'Telemetry disabled'
|
||||
|
||||
# ── Windows Update services disabled ──────────────────────────────────────
|
||||
foreach (`$svc in 'wuauserv','UsoSvc','WaaSMedicSvc') {
|
||||
sc.exe config `$svc start= disabled | Out-Null
|
||||
sc.exe stop `$svc | Out-Null
|
||||
}
|
||||
# ── Windows Update GPO locks — services left Manual (§7.2 Strategy B) ───
|
||||
# Set both WU services to Manual so they can be invoked on-demand by Setup
|
||||
# Step 6 (WU COM API via SYSTEM task) but won't auto-start between Deploy and Setup.
|
||||
# UsoSvc defaults to Automatic on Windows Server 2025; must be explicit here.
|
||||
# GPO keys below also block background triggers. Step 6b permanently disables both.
|
||||
Set-Service -Name wuauserv -StartupType Manual -ErrorAction SilentlyContinue
|
||||
Set-Service -Name UsoSvc -StartupType Manual -ErrorAction SilentlyContinue
|
||||
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' /v DisableWindowsUpdateAccess /t REG_DWORD /d 1 /f | Out-Null
|
||||
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null
|
||||
L 'Windows Update disabled'
|
||||
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\WinStations\RDP-Tcp' /v UserAuthentication /t REG_DWORD /d 0 /f | Out-Null
|
||||
Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue
|
||||
New-NetFirewallRule -DisplayName 'ICMPv4-In' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -ErrorAction SilentlyContinue | Out-Null
|
||||
L 'RDP + ICMP allowed'
|
||||
|
||||
# ── WinRM HTTP + HTTPS, Basic+Unencrypted, TrustedHosts=* (lab only) ─────
|
||||
# ── WinRM HTTPS-only/5986, Basic auth over HTTPS, TrustedHosts=* (lab only) ─
|
||||
# AllowUnencrypted intentionally left false — all WinRM traffic uses HTTPS/5986.
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null
|
||||
winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null
|
||||
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
|
||||
winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null
|
||||
winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null
|
||||
try {
|
||||
`$cert = New-SelfSignedCertificate -DnsName `$env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My
|
||||
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint `$cert.Thumbprint -Force | Out-Null
|
||||
New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any | Out-Null
|
||||
L 'WinRM HTTPS listener configured'
|
||||
New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any -RemoteAddress '192.168.79.0/24' | Out-Null
|
||||
L 'WinRM HTTPS listener configured (port 5986, restricted to 192.168.79.0/24)'
|
||||
} catch { L "WinRM HTTPS failed: `$(`$_.Exception.Message)" }
|
||||
winrm set winrm/config/winrs '@{MaxProcessesPerShell="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 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
|
||||
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null
|
||||
L 'Server Manager + WAC + CAD + OOBE prompts disabled'
|
||||
# Supplemental UX hardening validated by 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) ───────────────────────
|
||||
# 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
|
||||
L 'IE ESC disabled'
|
||||
|
||||
# ── Remove drive letter from EFI System Partition ────────────────────────
|
||||
# Windows setup sometimes assigns 'S:' to the ESP making it visible in Explorer.
|
||||
Get-Partition | Where-Object { `$_.DriveLetter -eq 'S' } | ForEach-Object {
|
||||
`$_ | Remove-PartitionAccessPath -AccessPath 'S:\' -ErrorAction SilentlyContinue
|
||||
}
|
||||
L 'EFI partition drive letter removed (if any)'
|
||||
|
||||
# ── Hibernate off (frees ~RAM-size on C:, no need on VMs) ────────────────
|
||||
powercfg /h off 2>&1 | Out-Null
|
||||
# Power plan High Performance — never sleep, never throttle CPU
|
||||
@@ -591,11 +717,51 @@ foreach (`$task in @(
|
||||
}
|
||||
L 'CEIP / feedback scheduled tasks disabled'
|
||||
|
||||
# ── Azure Arc auto-start / tray icon off ────────────────────────────────
|
||||
# Server 2025 ships with Azure Arc integration enabled; suppress for CI VMs.
|
||||
foreach (`$arcTask in @(
|
||||
'\Microsoft\Azure Arc\Onboarding\EnableAzureArcSetup',
|
||||
'\Microsoft\Azure Arc\Onboarding\ArcScan',
|
||||
'\Microsoft\AzureArcSeamlessOnboarding\ArcSetup'
|
||||
)) {
|
||||
schtasks /Change /TN `$arcTask /Disable 2>&1 | Out-Null
|
||||
}
|
||||
foreach (`$arcSvc in 'himds','ArcProxyAgent') {
|
||||
`$s = Get-Service -Name `$arcSvc -ErrorAction SilentlyContinue
|
||||
if (`$s) {
|
||||
sc.exe config `$arcSvc start= disabled | Out-Null
|
||||
sc.exe stop `$arcSvc 2>&1 | Out-Null
|
||||
}
|
||||
}
|
||||
reg add 'HKLM\SOFTWARE\Microsoft\AzureArc' /v OnboardingState /t REG_DWORD /d 4 /f 2>&1 | Out-Null
|
||||
reg delete 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' /v AzureArcSetup /f 2>&1 | Out-Null
|
||||
L 'Azure Arc auto-start disabled'
|
||||
|
||||
# ── Explorer UX: show file extensions + open to This PC ──────────────────
|
||||
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v HideFileExt /t REG_DWORD /d 0 /f | Out-Null
|
||||
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v LaunchTo /t REG_DWORD /d 1 /f | Out-Null
|
||||
L 'Explorer UX tweaks applied (HKCU)'
|
||||
|
||||
# ── Taskbar / terminal UX tweaks ─────────────────────────────────────────
|
||||
# SearchboxTaskbarMode=0 hide search bar
|
||||
# ShowTaskViewButton=0 hide Task View button
|
||||
# DelegationConsole/Terminal Windows Terminal as default terminal
|
||||
# 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) ──────────
|
||||
# Wrapper returned immediately while msiexec runs async. Poll until:
|
||||
# (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
|
||||
L 'install_complete.flag dropped'
|
||||
|
||||
# ── Reboot to apply Tools/UAC/services state cleanly ──────────────────────
|
||||
L 'rebooting in 15s'
|
||||
shutdown /r /t 15 /f /c "post-install complete"
|
||||
# ── Shut down (not restart) so host Step 8 can swap NIC and snapshot ──────
|
||||
# Delay 60s: host polls every 30s, needs one cycle after flag appears.
|
||||
# Step 8 sends vmrun stop soft on top of this — Windows handles both.
|
||||
L 'shutting down in 60s'
|
||||
shutdown /s /t 60 /f /c "post-install complete"
|
||||
"@
|
||||
$psOut = Join-Path $stagingDir 'post-install.ps1'
|
||||
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'))
|
||||
}
|
||||
|
||||
} # end Step 2
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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'
|
||||
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
|
||||
}
|
||||
|
||||
} # 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,
|
||||
# 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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($WinISONoPromptPath)) {
|
||||
$srcDir = Split-Path -Parent $WinISO
|
||||
$srcBase = [IO.Path]::GetFileNameWithoutExtension($WinISO)
|
||||
$WinISONoPromptPath = Join-Path $srcDir "$srcBase.patched.iso"
|
||||
}
|
||||
Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")' -Step 4
|
||||
|
||||
$srcMtime = (Get-Item $WinISO).LastWriteTime
|
||||
$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 non-empty (>500 MB)' { (Get-Item $WinISONoPromptPath).Length -gt 500MB }
|
||||
|
||||
} # end Step 4
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 5: generate VMX
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step 'Step 5: generate VMX'
|
||||
Write-Step 'Step 5: generate VMX' -Step 5
|
||||
if ($StartFromStep -le 5) {
|
||||
|
||||
# guestOS:
|
||||
# "windows2019srvnext-64" is the generic forward-compat ID for new Windows
|
||||
# Server releases on Workstation. Workstation 17.6+ accepts it for Server 2025.
|
||||
# If your Workstation version supports the explicit ID, change it here.
|
||||
$guestOS = 'windows2019srvnext-64'
|
||||
# ($guestOS is set unconditionally in the derived-paths block above.)
|
||||
|
||||
# CD-ROM ordering (sata):
|
||||
# sata0:0 = Windows install ISO (bootable) ← EFI picks this on first boot
|
||||
# sata0:1 = autounattend ISO (non-bootable, scanned by Setup)
|
||||
# sata0:2 = VMware Tools ISO (Tools installer source)
|
||||
# sata0:1 = autounattend ISO (non-bootable, scanned by Setup; Tools bundled inside)
|
||||
# Both disconnected in Step 8 after install — snapshot/clone boots clean with no ISOs.
|
||||
$vmxLines = @(
|
||||
'.encoding = "windows-1252"',
|
||||
'config.version = "8"',
|
||||
@@ -774,7 +945,7 @@ $vmxLines = @(
|
||||
"nvme0:0.fileName = `"$vmdkName`"",
|
||||
'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.pciSlotNumber = "32"',
|
||||
'sata0:0.present = "TRUE"',
|
||||
@@ -785,10 +956,6 @@ $vmxLines = @(
|
||||
'sata0:1.deviceType = "cdrom-image"',
|
||||
"sata0:1.fileName = `"$autounattendIso`"",
|
||||
'sata0:1.startConnected = "TRUE"',
|
||||
'sata0:2.present = "TRUE"',
|
||||
'sata0:2.deviceType = "cdrom-image"',
|
||||
"sata0:2.fileName = `"$ToolsISO`"",
|
||||
'sata0:2.startConnected = "TRUE"',
|
||||
'',
|
||||
'# NIC: e1000e for install (broad compat); switched to vmxnet3 after Tools',
|
||||
'ethernet0.present = "TRUE"',
|
||||
@@ -822,19 +989,23 @@ $vmxLines = @(
|
||||
Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII
|
||||
Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath }
|
||||
|
||||
} # end Step 5
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 6: power on VM
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step 'Step 6: power on VM (vmrun start nogui)'
|
||||
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null
|
||||
Start-Sleep -Seconds 5
|
||||
$running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||
Assert-Step 'PowerOn' 'VM appears in vmrun list' { $running -match [regex]::Escape($VMXPath) }
|
||||
Write-Step "Step 6: power on VM (vmrun start $vmrunUiMode)" -Step 6
|
||||
if ($StartFromStep -le 6) {
|
||||
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
|
||||
Start-Sleep -Seconds 10
|
||||
Assert-Step 'PowerOn' 'VM appears in vmrun list' { Test-VmInList -VmxPath $VMXPath }
|
||||
} # end Step 6
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
$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 }
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 8: graceful soft-stop, swap NIC e1000e → vmxnet3
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Write-Step 'Step 8: stop VM softly, swap NIC to vmxnet3'
|
||||
} # end Step 7
|
||||
|
||||
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).
|
||||
$stopDeadline = (Get-Date).AddMinutes(3)
|
||||
do {
|
||||
Start-Sleep -Seconds 5
|
||||
$still = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||
} while ((Get-Date) -lt $stopDeadline -and ($still -match [regex]::Escape($VMXPath)))
|
||||
Assert-Step 'Stop' 'VM no longer in vmrun list' {
|
||||
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||
-not ($list -match [regex]::Escape($VMXPath))
|
||||
Stop-Vm -VmxPath $VMXPath
|
||||
Assert-Step 'Stop' 'VM no longer in vmrun list' { -not (Test-VmInList -VmxPath $VMXPath) }
|
||||
|
||||
# Remove CD-ROM controller and USB from VMX — not needed post-install.
|
||||
foreach ($prefix in 'sata0','usb','ehci','usb_xhci') {
|
||||
Remove-VmxLines -VmxPath $VMXPath -KeyPrefix $prefix
|
||||
}
|
||||
Assert-Step 'CleanVmx' 'CD-ROM and USB entries removed from VMX' {
|
||||
$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'
|
||||
@@ -876,12 +1049,16 @@ Assert-Step 'NicSwap' 'ethernet0.virtualDev = vmxnet3' {
|
||||
(Get-Content -LiteralPath $VMXPath) -match '^\s*ethernet0\.virtualDev\s*=\s*"vmxnet3"\s*$'
|
||||
}
|
||||
|
||||
} # end Step 8
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
$guestIP = $null
|
||||
do {
|
||||
@@ -891,27 +1068,23 @@ do {
|
||||
} 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+' }
|
||||
|
||||
} # end Step 9
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 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
|
||||
$stopDeadline = (Get-Date).AddMinutes(3)
|
||||
do {
|
||||
Start-Sleep -Seconds 5
|
||||
$running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||
} while ((Get-Date) -lt $stopDeadline -and ($running -match [regex]::Escape($VMXPath)))
|
||||
Assert-Step 'Snapshot' 'VM powered off before snapshot' {
|
||||
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||
-not ($list -match [regex]::Escape($VMXPath))
|
||||
}
|
||||
Stop-Vm -VmxPath $VMXPath
|
||||
Assert-Step 'Snapshot' 'VM powered off before snapshot' { -not (Test-VmInList -VmxPath $VMXPath) }
|
||||
|
||||
Invoke-Vmrun -Arguments @('-T','ws','snapshot',$VMXPath,$SnapshotName) | Out-Null
|
||||
$snaps = Invoke-Vmrun -Arguments @('-T','ws','listSnapshots',$VMXPath)
|
||||
Assert-Step 'Snapshot' "Snapshot '$SnapshotName' present" { $snaps -match [regex]::Escape($SnapshotName) }
|
||||
|
||||
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null
|
||||
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) -Async
|
||||
Start-Sleep -Seconds 10
|
||||
$ipDeadline = (Get-Date).AddMinutes(5)
|
||||
do {
|
||||
Start-Sleep -Seconds 10
|
||||
@@ -919,6 +1092,8 @@ do {
|
||||
} 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+' }
|
||||
|
||||
} # end Step 10
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Final summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -932,7 +1107,10 @@ Write-Host " Admin user : $AdminUser"
|
||||
Write-Host " Snapshot : $SnapshotName"
|
||||
Write-Host ""
|
||||
Write-Host " RDP : mstsc /v:$guestIP"
|
||||
Write-Host " WinRM HTTP : 5985"
|
||||
Write-Host " WinRM HTTPS : 5986 (self-signed)"
|
||||
Write-Host " WinRM HTTPS : 5986 (self-signed, AllowUnencrypted=false)"
|
||||
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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -5,11 +5,12 @@
|
||||
|
||||
.DESCRIPTION
|
||||
Automates the template VM provisioning from the host machine:
|
||||
1. Pre-flight validation: script presence, IP octet range, TCP/5985 reachable
|
||||
2. Configures host WinRM client (AllowUnencrypted, TrustedHosts) — restored on exit
|
||||
3. Validates host WinRM client settings applied correctly
|
||||
4. Tests WinRM connectivity (Test-WSMan) — fails fast with actionable message
|
||||
5. Opens PSSession to the VM
|
||||
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-WinBuild2025.ps1 into the VM; validates file present + size match
|
||||
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
|
||||
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 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.
|
||||
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)
|
||||
$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)
|
||||
$winrmReady = $false
|
||||
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) {
|
||||
$winrmReady = $true; break
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
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.
|
||||
if ($BuildPassword -eq '') {
|
||||
@@ -261,59 +262,53 @@ else {
|
||||
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
# ── Configure HOST-side WinRM client (required for Basic auth over HTTP) ──────
|
||||
# AllowUnencrypted is needed for HTTP/5985 Basic auth. We save the prior value
|
||||
# and restore it in the finally block so this script doesn't permanently change
|
||||
# the host security posture.
|
||||
Write-Host "[Prepare] Configuring host WinRM client for unencrypted Basic auth (will be restored on exit)..."
|
||||
$prevAllowUnencrypted = $null
|
||||
# ── 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 {
|
||||
$prevAllowUnencrypted = (Get-Item WSMan:\localhost\Client\AllowUnencrypted -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
|
||||
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 WinRM client configured."
|
||||
# Validation
|
||||
Assert-Step 'HostWinRM' 'Client/AllowUnencrypted=true' {
|
||||
(Get-Item WSMan:\localhost\Client\AllowUnencrypted).Value -eq 'true'
|
||||
}
|
||||
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 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 " Set-Item WSMan:\localhost\Client\AllowUnencrypted `$true -Force"
|
||||
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
|
||||
Write-Warning "Then re-run this script."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
Write-Host "[Prepare] Testing WinRM connectivity to $VMIPAddress..."
|
||||
try {
|
||||
Test-WSMan -ComputerName $VMIPAddress -Credential $credential `
|
||||
-Authentication Basic -ErrorAction Stop | Out-Null
|
||||
Write-Host "[Prepare] WinRM reachable."
|
||||
}
|
||||
catch {
|
||||
# ── 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 reach $VMIPAddress via WinRM.
|
||||
[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
|
||||
|
||||
Ensure:
|
||||
- The VM is running and on VMnet8 (NAT) with internet access
|
||||
- WinRM is enabled inside the VM (run as Administrator in VM):
|
||||
winrm quickconfig -q
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
|
||||
- Windows Firewall allows port 5985 inbound
|
||||
- WinRM HTTPS is enabled inside the VM — Deploy-WinBuild2025.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: $_
|
||||
@@ -321,15 +316,6 @@ Error: $_
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Open session ──────────────────────────────────────────────────────────────
|
||||
Write-Host "[Prepare] Opening PSSession..."
|
||||
$session = New-PSSession `
|
||||
-ComputerName $VMIPAddress `
|
||||
-Credential $credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
|
||||
try {
|
||||
# ── Copy Setup-WinBuild2025.ps1 into the VM ───────────────────────────────
|
||||
$setupScript = Join-Path $scriptDir 'Setup-WinBuild2025.ps1'
|
||||
@@ -380,6 +366,7 @@ try {
|
||||
BuildPassword = $BuildPassword
|
||||
BuildUsername = $BuildUsername
|
||||
DotNetSdkVersion = $DotNetSdkVersion
|
||||
AdminPassword = $AdminPassword
|
||||
}
|
||||
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
|
||||
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
|
||||
@@ -422,9 +409,12 @@ try {
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Seconds 10
|
||||
try {
|
||||
$r = Test-WSMan -ComputerName $IP -Credential $Cred `
|
||||
# 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
|
||||
if ($r) { return $true }
|
||||
Remove-PSSession $probe -ErrorAction SilentlyContinue
|
||||
return $true
|
||||
} catch { }
|
||||
}
|
||||
return $false
|
||||
@@ -437,7 +427,17 @@ try {
|
||||
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"
|
||||
@@ -448,18 +448,19 @@ try {
|
||||
throw "Setup-WinBuild2025.ps1 exited with code $result"
|
||||
}
|
||||
|
||||
# exit 3010 → WU applied updates needing reboot. Reboot guest, wait,
|
||||
# reopen session, loop. -SkipWindowsUpdate must NOT be set on retry —
|
||||
# we want the next iteration to drain remaining updates.
|
||||
# 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)..."
|
||||
@@ -469,7 +470,8 @@ try {
|
||||
}
|
||||
Write-Host "[Prepare] Guest WinRM ready, reopening session..."
|
||||
$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 ──────────────────────────────────────────
|
||||
@@ -658,12 +660,9 @@ try {
|
||||
finally {
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
|
||||
# Restore host WinRM client settings to their original values
|
||||
if ($null -ne $prevAllowUnencrypted) {
|
||||
Set-Item WSMan:\localhost\Client\AllowUnencrypted $prevAllowUnencrypted -Force -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 WinRM client settings restored."
|
||||
Write-Host "[Prepare] Host TrustedHosts restored."
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+231
-172
@@ -8,40 +8,42 @@
|
||||
This script is run ONE TIME inside the template VM before taking the
|
||||
"BaseClean" snapshot. After the snapshot is taken, never modify the VM.
|
||||
|
||||
Installs and configures (each step followed by Assert-Step validation):
|
||||
Step 1 — Firewall: all profiles disabled first — removes any block on subsequent steps
|
||||
Prerequisite: Deploy-WinBuild2025.ps1 must have completed first (unattended OS
|
||||
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
|
||||
Step 2 — Defender state validation only (disabled by Deploy-WinBuild2025
|
||||
during unattended install via DisableAntiSpyware GPO + RTP off).
|
||||
Validation: GPO DisableAntiSpyware=1. No exclusion paths — with
|
||||
Defender off the scanner isn't running, so exclusions are moot.
|
||||
Step 3 — WinRM: Basic auth, AllowUnencrypted, MaxMemoryPerShellMB=2048, 2h timeout
|
||||
Firewall + Defender already off — no interference during config
|
||||
Validation: service Running+Automatic, AllowUnencrypted, Auth/Basic, memory
|
||||
Step 2 — Defender: validation only — Deploy set DisableAntiSpyware=1 GPO + RTP off.
|
||||
Validation: GPO DisableAntiSpyware=1
|
||||
Step 3 — WinRM: validation only — Deploy ran Enable-PSRemoting, Basic auth over HTTPS,
|
||||
AllowUnencrypted=false (HTTPS-only), MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=25.
|
||||
Validation: service Running+Automatic, AllowUnencrypted=false, Auth/Basic, memory
|
||||
Step 3b — Windows KMS activation via slmgr /skms + /ato (best-effort, non-fatal).
|
||||
Validation: LicenseStatus=1 check (warning-only on failure)
|
||||
Step 4 — Local build user account ($BuildUsername, PasswordNeverExpires, Administrators)
|
||||
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
|
||||
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
|
||||
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
|
||||
Step 5c — CI UX hardening (all UI tweaks before the long-running WU step):
|
||||
Server Manager auto-start off (machine policy + HKCU Administrator +
|
||||
Default User hive → ci_build inherits at first logon)
|
||||
Ctrl+Alt+Del at login disabled (DisableCAD=1) — CI VMs never need it
|
||||
OOBE privacy/telemetry prompt suppressed (DisablePrivacyExperience=1,
|
||||
AllowTelemetry=0) — prevents first-logon interactive prompt in clones
|
||||
Validation: machine policy key, HKCU key, DisableCAD, OOBE policy, telemetry
|
||||
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
|
||||
Step 5c — CI UX hardening: validation only — Deploy set lock screen, Server Manager
|
||||
(policy + HKLM + task + HKCU + Default hive), DisableCAD (Policies\System
|
||||
+ Winlogon), OOBE/telemetry suppressed.
|
||||
Validation: NoLockScreen, SM policy, SM task, DisableCAD, OOBE, telemetry
|
||||
Step 6 — Windows Update via Scheduled Task as SYSTEM (avoids WinRM token limit).
|
||||
Clears Deploy's GPO locks (Step A), starts services (Step D), runs COM API,
|
||||
reports result code. Skipped with -SkipWindowsUpdate.
|
||||
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,
|
||||
DisableWindowsUpdateAccess=1; WU scheduled tasks disabled
|
||||
Runs unconditionally (even with -SkipWindowsUpdate) so snapshot
|
||||
captures WU off — linked clones never trigger background updates
|
||||
DisableWindowsUpdateAccess=1; WU scheduled tasks disabled.
|
||||
Snapshot captures WU permanently off — linked clones never auto-update.
|
||||
Validation: wuauserv StartType=Disabled, GPO keys present
|
||||
Step 7 — .NET SDK (channel $DotNetSdkVersion) via dotnet-install.ps1
|
||||
Validation: dotnet resolvable, version channel match, machine PATH
|
||||
@@ -50,28 +52,27 @@
|
||||
Step 9 — Visual Studio Build Tools 2026 via Scheduled Task as SYSTEM
|
||||
Validation: MSBuild.exe exists, executes, PATH, VC dir present
|
||||
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
|
||||
wuauserv already Disabled (Step 6b) — not restarted after cache clear
|
||||
SoftwareDistribution\Download cleared best-effort (WaaSMedicSvc may repopulate)
|
||||
Validation: wuauserv StartType Disabled (SoftwareDistribution check removed —
|
||||
WaaSMedicSvc tamper-protection restores files immediately)
|
||||
Validation: wuauserv StartType Disabled
|
||||
Final — Pre-snapshot gate: 7 cross-cutting checks across all steps
|
||||
Throws if any check fails — prevents snapshot of broken state
|
||||
|
||||
Step ordering rationale:
|
||||
Firewall first — removes any network block before WinRM config and WU downloads
|
||||
Defender second — eliminates scan overhead before WinRM, WU, and installer downloads
|
||||
WinRM third — configured clean with no Firewall/Defender interference
|
||||
User + UAC — quick registry ops; UAC off before dirs/tools avoids elevation prompts
|
||||
CI dirs — C:\CI must exist before WU worker writes temp files there
|
||||
UI tweaks — all fast registry writes batched before the slow WU step
|
||||
WU sixth — all prereqs (dirs, user, tweaks) done; Firewall+Defender off
|
||||
WU disable — immediately after WU completes; snapshot captures WU permanently off
|
||||
Toolchain — .NET / Python / VS last; WU done, no scan on installer payloads
|
||||
Firewall (Step 1) — validates Deploy disabled all profiles; assertion before WinRM/WU
|
||||
Defender (Step 2) — validates Deploy disabled RTP; assertion before tool install
|
||||
WinRM (Step 3) — validates Deploy configured WinRM; assertion before remote steps
|
||||
User (Step 4) — creates ci_build; must exist before UAC and dirs assertions
|
||||
UAC (Step 4b) — validates Deploy disabled UAC (elevation-free installs in 7-9)
|
||||
CI dirs (Step 5) — C:\CI must exist before WU worker writes temp files there
|
||||
UI tweaks (5b/5c) — validates Deploy UX settings; fast assertions before slow WU
|
||||
WU (Step 6) — all prereqs validated; WU runs with Firewall+Defender already off
|
||||
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
|
||||
the source tree into the VM via WinRM (Option B isolation model).
|
||||
NOTE: Git is NOT installed by default. See §6.6 (Tier-1 Toolchain) in TODO.md
|
||||
for the planned addition. The host clones and copies source via WinRM until then.
|
||||
|
||||
╔══════════════════════════════════════════════════════════════════════╗
|
||||
║ NETWORK REQUIREMENT DURING SETUP ║
|
||||
@@ -132,7 +133,11 @@ param(
|
||||
[switch] $SkipWindowsUpdate,
|
||||
|
||||
# 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
|
||||
@@ -158,6 +163,44 @@ function Assert-Step {
|
||||
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 ───────────────
|
||||
# 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.
|
||||
@@ -173,16 +216,12 @@ foreach ($f in $knownTempFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 1: Firewall ──────────────────────────────────────────────────────────
|
||||
Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM"
|
||||
# ── Step 1: Firewall (validation only — disabled by Deploy-WinBuild2025) ──────
|
||||
Write-Step "Firewall state (validation only — disabled at deploy time)"
|
||||
|
||||
# Disable first: 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, so full disable is acceptable.
|
||||
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
|
||||
Write-Host "Windows Firewall disabled on all profiles."
|
||||
# Deploy-WinBuild2025.ps1 post-install.ps1 called Set-NetFirewallProfile -Enabled False
|
||||
# on all profiles. Validated here before WinRM and WU assertions proceed.
|
||||
|
||||
# Validation
|
||||
foreach ($p in 'Domain','Private','Public') {
|
||||
Assert-Step 'Firewall' "$p profile disabled" {
|
||||
(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)
|
||||
}
|
||||
|
||||
# ── Step 3: WinRM ─────────────────────────────────────────────────────────────
|
||||
Write-Step "Configuring WinRM"
|
||||
# ── Step 3: WinRM (validation only — configured by Deploy-WinBuild2025) ───────
|
||||
Write-Step "WinRM state (validation only — configured at deploy time)"
|
||||
|
||||
# Firewall and Defender already off — no interference during WinRM hardening.
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null
|
||||
# Deploy-WinBuild2025.ps1 post-install.ps1 ran Enable-PSRemoting, set Basic auth over HTTPS,
|
||||
# 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' {
|
||||
(Get-Service WinRM -ErrorAction Stop).Status -eq 'Running'
|
||||
}
|
||||
Assert-Step 'WinRM' 'service StartType Automatic' {
|
||||
(Get-Service WinRM -ErrorAction Stop).StartType -eq 'Automatic'
|
||||
}
|
||||
Assert-Step 'WinRM' 'AllowUnencrypted=true' {
|
||||
(Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'true'
|
||||
Assert-Step 'WinRM' 'AllowUnencrypted=false (HTTPS-only)' {
|
||||
(Get-Item WSMan:\localhost\Service\AllowUnencrypted).Value -eq 'false'
|
||||
}
|
||||
Assert-Step 'WinRM' 'Auth/Basic=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
|
||||
}
|
||||
|
||||
# ── 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 ───────────────────────────────────────────────
|
||||
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)
|
||||
}
|
||||
|
||||
# ── Step 4b: Disable UAC ─────────────────────────────────────────────────────
|
||||
Write-Step "Disabling UAC (isolated lab VM)"
|
||||
# ── Step 4b: UAC (validation only — disabled by Deploy-WinBuild2025) ─────────
|
||||
Write-Step "UAC state (validation only — disabled at deploy time)"
|
||||
|
||||
# EnableLUA=0 disables UAC entirely — admin processes get full token without prompt.
|
||||
# LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also
|
||||
# get an elevated token (avoids the filtered-token issue with runProgramInGuest).
|
||||
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."
|
||||
# Deploy-WinBuild2025.ps1 post-install.ps1 set EnableLUA=0 (no prompt for admin
|
||||
# processes) and LocalAccountTokenFilterPolicy=1 (WinRM remote connections get full
|
||||
# elevated token, required for runProgramInGuest and remote admin commands).
|
||||
|
||||
# Validation
|
||||
$uacPolicyKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
Assert-Step 'UAC' 'EnableLUA=0' {
|
||||
(Get-ItemProperty -Path $uacPolicyKey -Name EnableLUA -ErrorAction Stop).EnableLUA -eq 0
|
||||
@@ -335,109 +379,32 @@ foreach ($dir in $ciDirs) {
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 5b: Explorer settings ───────────────────────────────────────────────
|
||||
Write-Step "Configuring Explorer defaults"
|
||||
# ── Step 5b: Explorer settings (validation only — configured by Deploy-WinBuild2025) ──
|
||||
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'
|
||||
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)" {
|
||||
(Get-ItemProperty -Path $explorerAdvKey -Name LaunchTo -ErrorAction Stop).LaunchTo -eq 1
|
||||
}
|
||||
|
||||
# ── Step 5c: CI UX hardening ─────────────────────────────────────────────────
|
||||
Write-Step "CI UX hardening (lock screen off, Server Manager off, no CAD, no OOBE)"
|
||||
# ── Step 5c: CI UX hardening (validation only — configured by Deploy-WinBuild2025) ─
|
||||
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'
|
||||
if (-not (Test-Path $personPolicyKey)) { New-Item -Path $personPolicyKey -Force | Out-Null }
|
||||
Set-ItemProperty -Path $personPolicyKey -Name 'NoLockScreen' -Value 1 -Type DWord -Force
|
||||
Write-Host "Lock screen disabled."
|
||||
|
||||
# 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'
|
||||
Set-ItemProperty -Path $systemPolicyKey -Name 'DisableCAD' -Value 1 -Type DWord -Force
|
||||
Write-Host "Ctrl+Alt+Del at login disabled (Winlogon + Policies\System)."
|
||||
|
||||
# 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)' {
|
||||
(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' {
|
||||
(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 ────────────────────────────────────────────────────
|
||||
# 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-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
|
||||
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-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)."
|
||||
|
||||
# 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
|
||||
Assert-Step 'WUDisable' 'wuauserv StartType Disabled' {
|
||||
(Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled'
|
||||
@@ -670,6 +686,8 @@ else {
|
||||
$dotnetInstallScript = "$env:TEMP\dotnet-install.ps1"
|
||||
Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' `
|
||||
-OutFile $dotnetInstallScript -UseBasicParsing
|
||||
Assert-Hash -FilePath $dotnetInstallScript -Label 'dotnet-install.ps1' `
|
||||
-Expected $script:Hashes['DotNetInstallScript']
|
||||
|
||||
Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..."
|
||||
& $dotnetInstallScript `
|
||||
@@ -716,6 +734,8 @@ else {
|
||||
$pyInstallerPath = 'C:\CI\python_installer.exe'
|
||||
Write-Host "Downloading Python $PythonVersion installer..."
|
||||
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)..."
|
||||
$pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @(
|
||||
@@ -770,6 +790,8 @@ else {
|
||||
Write-Host "Downloading VS Build Tools installer..."
|
||||
Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy
|
||||
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).
|
||||
# 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."
|
||||
|
||||
# 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
|
||||
# Note: do NOT assert SoftwareDistribution\Download empty.
|
||||
# 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).
|
||||
# Report remaining size as informational only.
|
||||
$sdFiles = Get-ChildItem 'C:\Windows\SoftwareDistribution\Download' -Recurse -File -Force -ErrorAction SilentlyContinue
|
||||
$sdMB = [math]::Round(($sdFiles | Measure-Object -Property Length -Sum).Sum / 1MB, 1)
|
||||
Write-Host " SoftwareDistribution\Download: $($sdFiles.Count) file(s), ${sdMB} MB remaining (WaaSMedicSvc best-effort, WU is disabled)."
|
||||
# Avoid Measure-Object .Sum: under Set-StrictMode -Version Latest, PS 5.1 treats the
|
||||
# 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'
|
||||
}
|
||||
} # 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 ────────────────────────────────────────────
|
||||
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 $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' {
|
||||
(Get-Service wuauserv -ErrorAction Stop).StartType -eq 'Disabled'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user