Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea882c99d7 | |||
| 3c4dd68282 | |||
| 96b65d40de | |||
| ab636a0ec1 | |||
| 889bd9e41b | |||
| 644258f59c | |||
| 0f21e61668 | |||
| d4099b94fd | |||
| 44f4c3ce7e | |||
| a0d66f78c4 | |||
| 0d2218cd4d | |||
| 41212bd439 | |||
| 2e568da986 | |||
| 726599edfc | |||
| 53b7ff05fd | |||
| d2b20284d1 | |||
| e67cfa6789 | |||
| e6d44dad06 | |||
| a1b8ad1c11 | |||
| 2f2aa2bd9c | |||
| 8a37d6c8bf | |||
| cc9e5f3969 | |||
| 7546d96027 | |||
| f373c0c24b | |||
| ecd79c2219 |
@@ -42,3 +42,7 @@ $RECYCLE.BIN/
|
|||||||
# Temp
|
# Temp
|
||||||
*.tmp
|
*.tmp
|
||||||
~$*
|
~$*
|
||||||
|
|
||||||
|
# Copilot Orchestrator temporary files
|
||||||
|
.orchestrator/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
|
|||||||
│ └── Remove-BuildVM.ps1 # Stop + rimozione VM clone
|
│ └── Remove-BuildVM.ps1 # Stop + rimozione VM clone
|
||||||
│
|
│
|
||||||
├── template/
|
├── template/
|
||||||
│ ├── Prepare-TemplateSetup.ps1 # Provisioning automatico template VM (HOST-side)
|
│ ├── Prepare-WinBuild2025.ps1 # Provisioning automatico template VM (HOST-side)
|
||||||
│ └── Setup-TemplateVM.ps1 # Installazione toolchain nella VM (GUEST-side)
|
│ └── Setup-WinBuild2025.ps1 # Installazione toolchain nella VM (GUEST-side)
|
||||||
│
|
│
|
||||||
├── gitea/
|
├── gitea/
|
||||||
│ ├── workflows/
|
│ ├── workflows/
|
||||||
@@ -103,7 +103,7 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
|
|||||||
```powershell
|
```powershell
|
||||||
# Dalla directory template/, dopo aver installato Windows Server 2025 nella VM
|
# Dalla directory template/, dopo aver installato Windows Server 2025 nella VM
|
||||||
# e annotato il suo IP DHCP su VMnet8:
|
# e annotato il suo IP DHCP su VMnet8:
|
||||||
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.x -SkipWindowsUpdate
|
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.x -SkipWindowsUpdate
|
||||||
```
|
```
|
||||||
|
|
||||||
Poi spegni la VM e prendi lo snapshot con nome esatto **`BaseClean`**.
|
Poi spegni la VM e prendi lo snapshot con nome esatto **`BaseClean`**.
|
||||||
@@ -114,7 +114,7 @@ Poi spegni la VM e prendi lo snapshot con nome esatto **`BaseClean`**.
|
|||||||
# PowerShell elevato — una volta sola sull'host
|
# PowerShell elevato — una volta sola sull'host
|
||||||
Import-Module CredentialManager
|
Import-Module CredentialManager
|
||||||
New-StoredCredential -Target "BuildVMGuest" -UserName "ci_build" `
|
New-StoredCredential -Target "BuildVMGuest" -UserName "ci_build" `
|
||||||
-Password "CIBuild!ChangeMe2026" -Persist LocalMachine
|
-Password "<your-build-password>" -Persist LocalMachine
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. act_runner
|
### 3. act_runner
|
||||||
|
|||||||
+9
-6
@@ -82,7 +82,7 @@ param(
|
|||||||
[string] $CIRoot = 'F:\CI',
|
[string] $CIRoot = 'F:\CI',
|
||||||
[string] $GuestCredentialTarget = 'BuildVMGuest',
|
[string] $GuestCredentialTarget = 'BuildVMGuest',
|
||||||
[string] $GuestUsername = 'ci_build',
|
[string] $GuestUsername = 'ci_build',
|
||||||
[string] $GuestPassword = 'CIBuild!ChangeMe2026',
|
[string] $GuestPassword = '',
|
||||||
[string] $ActRunnerExe = '',
|
[string] $ActRunnerExe = '',
|
||||||
[string] $ActRunnerConfigYaml = '',
|
[string] $ActRunnerConfigYaml = '',
|
||||||
[string] $GiteaUrl = 'http://10.10.20.11:3100',
|
[string] $GiteaUrl = 'http://10.10.20.11:3100',
|
||||||
@@ -167,16 +167,19 @@ if ($existingCred) {
|
|||||||
Write-OK "Credential '$GuestCredentialTarget' already exists (user: $($existingCred.UserName))"
|
Write-OK "Credential '$GuestCredentialTarget' already exists (user: $($existingCred.UserName))"
|
||||||
Write-Warn "To update: Remove-StoredCredential -Target '$GuestCredentialTarget', then re-run."
|
Write-Warn "To update: Remove-StoredCredential -Target '$GuestCredentialTarget', then re-run."
|
||||||
} else {
|
} else {
|
||||||
|
if ($GuestPassword -eq '') {
|
||||||
|
Write-Host " No -GuestPassword supplied. Enter password for '$GuestUsername' (will be stored in Credential Manager):"
|
||||||
|
$secPwd = Read-Host -Prompt " Password" -AsSecureString
|
||||||
|
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
|
||||||
|
$GuestPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||||||
|
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||||
|
}
|
||||||
New-StoredCredential `
|
New-StoredCredential `
|
||||||
-Target $GuestCredentialTarget `
|
-Target $GuestCredentialTarget `
|
||||||
-UserName $GuestUsername `
|
-UserName $GuestUsername `
|
||||||
-Password $GuestPassword `
|
-Password $GuestPassword `
|
||||||
-Persist LocalMachine | Out-Null
|
-Persist LocalMachine | Out-Null
|
||||||
Write-OK "Credential '$GuestCredentialTarget' stored (user: $GuestUsername)"
|
Write-OK "Credential '$GuestCredentialTarget' stored (user: $GuestUsername)"
|
||||||
if ($GuestPassword -eq 'CIBuild!ChangeMe2026') {
|
|
||||||
Write-Warn "Using DEFAULT password — change it before production use!"
|
|
||||||
Write-Warn "Edit -GuestPassword parameter and re-run, or update in Credential Manager manually."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Step 5: Copy runner\config.yaml ──────────────────────────────────────────
|
# ── Step 5: Copy runner\config.yaml ──────────────────────────────────────────
|
||||||
@@ -341,7 +344,7 @@ Write-Host " NIC: VMnet8 (NAT) — internet required during provisioning"
|
|||||||
Write-Host " b. Install Windows Server 2025 + enable WinRM inside VM"
|
Write-Host " b. Install Windows Server 2025 + enable WinRM inside VM"
|
||||||
Write-Host " c. From this host run:"
|
Write-Host " c. From this host run:"
|
||||||
Write-Host " cd $scriptDir\template"
|
Write-Host " cd $scriptDir\template"
|
||||||
Write-Host " .\Prepare-TemplateSetup.ps1 -VMIPAddress <VM_IP> -SkipWindowsUpdate"
|
Write-Host " .\Prepare-WinBuild2025.ps1 -VMIPAddress <VM_IP> -SkipWindowsUpdate"
|
||||||
Write-Host " d. Shut down VM, take snapshot named exactly: BaseClean"
|
Write-Host " d. Shut down VM, take snapshot named exactly: BaseClean"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " 4. Register SSH key with Gitea:"
|
Write-Host " 4. Register SSH key with Gitea:"
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
# TODO — Local CI/CD System
|
# TODO — Local CI/CD System
|
||||||
|
|
||||||
<!-- Last updated: 2026-05-08 — e2e-009 SUCCESS, sistema production-ready -->
|
<!-- Last updated: 2026-05-10 — line refs e wording allineati al refactor 2026-05-09 -->
|
||||||
|
|
||||||
|
> Documento unico di lavoro: roadmap, audit trail dei task completati, e backlog post-v1.0
|
||||||
|
> con priorità e razionale. Le voci aperte sono raggruppate per area e marcate **P0..P3**:
|
||||||
|
>
|
||||||
|
> **Doc di setup operativi**:
|
||||||
|
> - [docs/HOST-SETUP.md](docs/HOST-SETUP.md) — bootstrap macchina host (Setup-Host.ps1)
|
||||||
|
> - [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md) — provisioning template Windows (Deploy + Prepare + Setup-WinBuild2025)
|
||||||
|
> - [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md) — bozza/TODO template Linux (non implementato)
|
||||||
|
>
|
||||||
|
> - **P0** — sicurezza o affidabilità: affrontare prima di estendere il sistema
|
||||||
|
> - **P1** — operatività quotidiana: automatizzare manutenzione e debug
|
||||||
|
> - **P2** — performance e qualità del codice
|
||||||
|
> - **P3** — estensioni nice-to-have
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Setup & Infrastructure
|
## Setup & Infrastructure
|
||||||
|
|
||||||
@@ -25,6 +40,7 @@
|
|||||||
- [x] `F:\CI\Cache\NuGet\`
|
- [x] `F:\CI\Cache\NuGet\`
|
||||||
- [x] `F:\CI\RunnerWork\`
|
- [x] `F:\CI\RunnerWork\`
|
||||||
- [x] `F:\CI\Templates\WinBuild\`
|
- [x] `F:\CI\Templates\WinBuild\`
|
||||||
|
- [x] `F:\CI\Logs\` — log per job (retention: 30 giorni)
|
||||||
- [x] `F:\CI\ISO\` — contiene `26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` (Windows Server 2025)
|
- [x] `F:\CI\ISO\` — contiene `26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` (Windows Server 2025)
|
||||||
|
|
||||||
- [x] **CredentialManager** PowerShell module v2.0 installato (Scope: CurrentUser)
|
- [x] **CredentialManager** PowerShell module v2.0 installato (Scope: CurrentUser)
|
||||||
@@ -38,7 +54,7 @@
|
|||||||
### Fase A — Crea e installa la VM (NIC: NAT per internet)
|
### Fase A — Crea e installa la VM (NIC: NAT per internet)
|
||||||
- [x] Crea nuova VM in VMware Workstation con queste impostazioni:
|
- [x] Crea nuova VM in VMware Workstation con queste impostazioni:
|
||||||
- 4 vCPU, 6144 MB RAM, disco 80 GB thin
|
- 4 vCPU, 6144 MB RAM, disco 80 GB thin
|
||||||
- VMX path: `F:\CI\Templates\WinBuild\WinBuild.vmx`
|
- VMX path: `F:\CI\Templates\WinBuild\CI-WinBuild.vmx`
|
||||||
- **NIC: VMnet8 (NAT)** ← internet necessario per Windows Update e installer
|
- **NIC: VMnet8 (NAT)** ← internet necessario per Windows Update e installer
|
||||||
- CD-ROM: `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso`
|
- CD-ROM: `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso`
|
||||||
- [x] Installa Windows Server 2025 dall'ISO
|
- [x] Installa Windows Server 2025 dall'ISO
|
||||||
@@ -67,23 +83,34 @@
|
|||||||
- [x] Dall'host, esegui:
|
- [x] Dall'host, esegui:
|
||||||
```powershell
|
```powershell
|
||||||
cd n:\Code\Workspace\Local-CI-CD-System\template
|
cd n:\Code\Workspace\Local-CI-CD-System\template
|
||||||
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.128 -SkipWindowsUpdate
|
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.128 -SkipWindowsUpdate
|
||||||
```
|
```
|
||||||
Installa: WinRM config, utente `ci_build`, .NET SDK 10.0, VS Build Tools 2026, Python 3.13.3.
|
Installa: WinRM config, utente `ci_build`, .NET SDK 10.0, VS Build Tools 2026, Python 3.13.3.
|
||||||
|
Lo script esegue validazione automatica ad ogni passaggio (`Assert-Step`):
|
||||||
|
- Pre-flight: IP ottetti 0-255, TCP/5985 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
|
||||||
|
Se qualsiasi check fallisce → script termina con errore → **non prendere lo snapshot**.
|
||||||
- [x] Setup completato con successo (VS Build Tools 2026 exit code 0).
|
- [x] Setup completato con successo (VS Build Tools 2026 exit code 0).
|
||||||
|
- [x] `Setup-WinBuild2025.ps1` ora esegue validazione `Assert-Step` dopo ogni step interno
|
||||||
|
(WinRM, Firewall, User, UAC, Dirs, Defender, .NET, Python, VS Build Tools, Toolchain,
|
||||||
|
Cleanup, Final pre-snapshot gate) — throws se qualsiasi check fallisce (2026-05-09)
|
||||||
|
|
||||||
### Fase C — Spegni e prendi lo snapshot
|
### Fase C — Spegni e prendi lo snapshot
|
||||||
- [x] VM rimane su **VMnet8 (NAT)** — internet access permanente per build che richiedono pip/nuget/npm
|
- [x] VM rimane su **VMnet8 (NAT)** — internet access permanente per build che richiedono pip/nuget/npm
|
||||||
- [x] Spegni la VM: Start → Shut down
|
- [x] Spegni la VM: Start → Shut down
|
||||||
- [x] Prendi snapshot: VM → Snapshot → Take Snapshot
|
- [x] Prendi snapshot: VM → Snapshot → Take Snapshot
|
||||||
**Nome esatto: `BaseClean`**
|
**Nome esatto: `BaseClean`**
|
||||||
|
**Prerequisito: `Prepare-WinBuild2025.ps1` deve essere uscito con exit 0** — tutti
|
||||||
|
gli `Assert-Step` passati, incluso il Final pre-snapshot gate nel guest.
|
||||||
- [x] Lascia la VM spenta — non modificarla mai più dopo questo snapshot
|
- [x] Lascia la VM spenta — non modificarla mai più dopo questo snapshot
|
||||||
|
|
||||||
### Fase D — Credenziali e config
|
### Fase D — Credenziali e config
|
||||||
- [x] Archivia credenziali guest sull'host (PowerShell elevato con CredentialManager):
|
- [x] Archivia credenziali guest sull'host (PowerShell elevato con CredentialManager):
|
||||||
```powershell
|
```powershell
|
||||||
New-StoredCredential -Target "BuildVMGuest" -UserName "ci_build" `
|
New-StoredCredential -Target "BuildVMGuest" -UserName "ci_build" `
|
||||||
-Password "CIBuild!ChangeMe2026" -Persist LocalMachine
|
-Password "<your-build-password>" -Persist LocalMachine
|
||||||
```
|
```
|
||||||
- [x] Verifica `GITEA_CI_TEMPLATE_PATH` in `F:\CI\act_runner\config.yaml` — aggiornato a `CI-WinBuild.vmx` (2026-05-08)
|
- [x] Verifica `GITEA_CI_TEMPLATE_PATH` in `F:\CI\act_runner\config.yaml` — aggiornato a `CI-WinBuild.vmx` (2026-05-08)
|
||||||
|
|
||||||
@@ -97,6 +124,8 @@
|
|||||||
- [x] Test `Invoke-CIJob.ps1` end-to-end su una soluzione reale (nsis-plugin-nsinnounp)
|
- [x] Test `Invoke-CIJob.ps1` end-to-end su una soluzione reale (nsis-plugin-nsinnounp)
|
||||||
- [x] e2e-008: SUCCESS — build OK + artifact raccolto (1632 KB)
|
- [x] e2e-008: SUCCESS — build OK + artifact raccolto (1632 KB)
|
||||||
- [x] e2e-009: SUCCESS — cleanup automatico VM + clone confermato (finally block)
|
- [x] e2e-009: SUCCESS — cleanup automatico VM + clone confermato (finally block)
|
||||||
|
- [x] `scripts/Cleanup-OrphanedBuildVMs.ps1` creato e verificato — `-WhatIf`, soglia `-MaxAgeHours`, hard stop + deleteVM + rimozione dir (2026-05-09)
|
||||||
|
- [x] `scripts/Remove-BuildVM.ps1` fix `vmrun deleteVM` exit -1 — poll `vmrun list` + retry 3× backoff 0/3/6s (2026-05-09)
|
||||||
|
|
||||||
## Runner Configuration
|
## Runner Configuration
|
||||||
|
|
||||||
@@ -111,18 +140,577 @@
|
|||||||
(workflow `build-nsis.yml` già presente e funzionante per `nsis-plugin-nsinnounp`)
|
(workflow `build-nsis.yml` già presente e funzionante per `nsis-plugin-nsinnounp`)
|
||||||
- [x] Push commit e verificare job in Gitea Actions UI
|
- [x] Push commit e verificare job in Gitea Actions UI
|
||||||
- [x] Verificare artifact scaricabile da Gitea dopo build riuscita
|
- [x] Verificare artifact scaricabile da Gitea dopo build riuscita
|
||||||
- [ ] Adattare workflow per altri repository (generalizzare `BUILD_COMMAND` e `ARTIFACT_SOURCE`)
|
- [x] `gitea/workflows/lint.yml` creato — PSScriptAnalyzer su push/PR che toccano `.ps1` (2026-05-09)
|
||||||
|
- [ ] **[P3] Adattare workflow per altri repository** — generalizzare `BUILD_COMMAND` e `ARTIFACT_SOURCE`.
|
||||||
|
Vedi anche §6.2 (composite action riutilizzabile).
|
||||||
|
|
||||||
## Performance & Maintenance
|
---
|
||||||
|
|
||||||
- [ ] Benchmark: tempo creazione clone + tempo WinRM readiness
|
## 1. Sicurezza & hardening
|
||||||
- [ ] Configurare NuGet package cache su shared folder host (vedi OPTIMIZATION.md)
|
|
||||||
- [ ] Pianificare refresh semestrale snapshot template VM (KMS lease = 180 giorni):
|
|
||||||
Boot template su VMnet8 (NAT) → `slmgr /ato` → nuovo snapshot BaseClean
|
|
||||||
- [ ] Monitoraggio Windows Event Log per fallimenti servizio act_runner
|
|
||||||
- [ ] Configurare retention artifact (pulizia `F:\CI\Artifacts` più vecchi di N giorni)
|
|
||||||
|
|
||||||
## In-VM Git Clone (Ottimizzazione)
|
### 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.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
### 1.2 [P0] Restringere `TrustedHosts` lato host (audit-only — già coperto)
|
||||||
|
File: nessuno script imposta `*` sull'host. Stato corrente:
|
||||||
|
- `Setup-Host.ps1` non tocca TrustedHosts (verificato 2026-05-10)
|
||||||
|
- [template/Prepare-WinBuild2025.ps1:270-294](template/Prepare-WinBuild2025.ps1:270) appende `$VMIPAddress` e ripristina nel `finally` (riga 617)
|
||||||
|
- [docs/HOST-SETUP.md:121-126](docs/HOST-SETUP.md:121) raccomanda `'192.168.79.*'` in setup manuale
|
||||||
|
|
||||||
|
**Azione residua**: audit eventuali host già configurati con `*` (eredità manuale) e
|
||||||
|
sostituire con la subnet build:
|
||||||
|
```powershell
|
||||||
|
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 [P0] Pinning hash SHA256 degli installer
|
||||||
|
File: [template/Setup-WinBuild2025.ps1:707-755](template/Setup-WinBuild2025.ps1) (Step 8 Python), [template/Setup-WinBuild2025.ps1:757-885](template/Setup-WinBuild2025.ps1) (Step 9 VS Build Tools), [template/Setup-WinBuild2025.ps1:660-705](template/Setup-WinBuild2025.ps1) (Step 7 dotnet-install.ps1).
|
||||||
|
|
||||||
|
**Motivazione**: Python, `dotnet-install.ps1`, `vs_buildtools.exe` vengono scaricati senza
|
||||||
|
verifica integrità. Un MITM nella rete dell'host (anche temporaneo) può iniettare binari
|
||||||
|
compromessi nel template, che poi viene snapshottato e usato da *ogni* build.
|
||||||
|
|
||||||
|
Pattern minimale:
|
||||||
|
```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" }
|
||||||
|
```
|
||||||
|
Aggiungere un blocco hash per ogni download e bloccarli in costanti `$script:Hashes`.
|
||||||
|
Aggiornare al cambio versione (è raro).
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
### 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".
|
||||||
|
|
||||||
|
Vincoli da rispettare in qualsiasi implementazione di `-UseGitClone`:
|
||||||
|
- [ ] PAT recuperato da Credential Manager **subito prima** della WinRM session, mai prima.
|
||||||
|
- [ ] Iniettato via `$using:cred` o env var di sessione, mai in argv né in log/transcript.
|
||||||
|
- [ ] Cancellato in `finally` interno alla scriptblock guest.
|
||||||
|
- [ ] **Grep automatico post-job sui log**: se il PAT compare grezzo in `$jobLog` o
|
||||||
|
`transcript.txt`, marcare la build come fallita e ruotare il PAT (regola di safety net,
|
||||||
|
non sostituisce le precedenti).
|
||||||
|
|
||||||
|
### 1.6 [P2] Defender + Firewall + UAC tutti disattivati nel template — documentare il modello di minaccia
|
||||||
|
File: [template/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):
|
||||||
|
- **Defender**: disabled da Deploy (`DisableAntiSpyware=1` GPO + RTP off) — Setup Step 2 è validation-only
|
||||||
|
- **Firewall**: disabled da Setup Step 1 (`Set-NetFirewallProfile ... -Enabled False`); §7.1 prevede spostamento a Deploy
|
||||||
|
- **UAC**: `EnableLUA=0`, `LocalAccountTokenFilterPolicy=1` — duplicato Deploy + Setup Step 4b (§7.1 lo riduce a Assert-Step in Setup)
|
||||||
|
|
||||||
|
Le scelte sono ragionevoli per un template lab efimero, ma vanno raccolte in un'unica sezione
|
||||||
|
`BEST-PRACTICES.md §X — Threat Model & Hardening Trade-offs` che indichi:
|
||||||
|
- Cosa è disattivato e perché (costo build vs. superficie d'attacco).
|
||||||
|
- Quali condizioni rendono il modello inaccettabile (es. condivisione dell'host, esposizione
|
||||||
|
VMnet8 a LAN aziendale, build di codice di terze parti non fidate).
|
||||||
|
- Mitigazioni se uno di quei vincoli salta (Firewall On con regola WinRM esplicita,
|
||||||
|
Defender con esclusione `C:\CI`, UAC On con `LocalAccountTokenFilterPolicy=1`).
|
||||||
|
|
||||||
|
Senza questa nota, future modifiche possono accumulare ulteriori riduzioni di sicurezza
|
||||||
|
senza tracciamento.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- [ ] Confermare che `Get-StoredCredential` funzioni correttamente in tutti gli script che
|
||||||
|
lo usano (post-migrazione HTTPS).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Affidabilità & resilienza
|
||||||
|
|
||||||
|
### 2.1 [P0] Race su IP allocation con `capacity: 4`
|
||||||
|
File: [runner/config.yaml:17](runner/config.yaml), [scripts/Invoke-CIJob.ps1:244-253](scripts/Invoke-CIJob.ps1).
|
||||||
|
|
||||||
|
**Stato osservato**: race non riprodotta in e2e-008/009 (un job alla volta). `capacity: 4`
|
||||||
|
resta attivo in produzione — bug teorico finché non si esegue stress test parallelo.
|
||||||
|
|
||||||
|
**Motivazione**: con 4 job paralleli e DHCP VMnet8, due VM possono ottenere lo stesso IP se
|
||||||
|
la lease pool è stretta o se due `vmrun start` rispondono troppo vicini.
|
||||||
|
`getGuestIPAddress` ritorna l'IP visto dalla VM stessa, quindi la collisione non viene
|
||||||
|
rilevata in fase di Wait-VMReady — apparirà come "WinRM risponde ma è la VM sbagliata".
|
||||||
|
|
||||||
|
**Opzioni**:
|
||||||
|
- **A** — IP allocator file-based con lock: prendere un IP libero da `192.168.79.101..104`,
|
||||||
|
scriverlo in `F:\CI\State\ip-leases\<jobid>` e iniettarlo come `bootArgs` o via
|
||||||
|
`vmrun writeVariable` prima di start. Rilascio nel `finally`.
|
||||||
|
- **B** — DHCP reservation per MAC nel VMware NAT DHCP server (`vmnetdhcp.conf`): assegnare
|
||||||
|
4 MAC noti, hard-coded nei VMX dei cloni. Più semplice ma richiede gestire il VMX clonato.
|
||||||
|
- **C** — pool fissato a `capacity: 1` finché non si implementa A o B.
|
||||||
|
|
||||||
|
L'opzione A è la più portabile e si integra con il pre-warm pool (§3.4).
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
```powershell
|
||||||
|
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||||||
|
-Argument '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "N:\Code\Workspace\Local-CI-CD-System\scripts\Cleanup-OrphanedBuildVMs.ps1"'
|
||||||
|
$trigger = New-ScheduledTaskTrigger -Daily -At 4am -RandomDelay (New-TimeSpan -Minutes 30)
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
|
||||||
|
Register-ScheduledTask -TaskName 'CI-CleanupOrphans' -Action $action -Trigger $trigger -Principal $principal -Force
|
||||||
|
```
|
||||||
|
Aggiungere anche un trigger `AtStartup` per gestire crash dell'host.
|
||||||
|
Suggerito: ogni 6 ore, `-MaxAgeHours 4`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
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`
|
||||||
|
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>`
|
||||||
|
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
|
||||||
|
flusso attuale richiede di sovrascrivere `BaseClean` — i cloni esistenti diventano invalidi
|
||||||
|
e non c'è rollback.
|
||||||
|
|
||||||
|
**Strategia**:
|
||||||
|
- Naming: `BaseClean_20260509`.
|
||||||
|
- `runner/config.yaml` aggiunge `GITEA_CI_SNAPSHOT_NAME` come env var.
|
||||||
|
- `Invoke-CIJob.ps1` legge da env, default `'BaseClean'` per compat.
|
||||||
|
- Pratica: tieni gli ultimi 2 snapshot, cancella il più vecchio dopo 1 settimana di uso pulito del nuovo.
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
`online` → `Restart-Service act_runner` + log evento. Limitare a 3 restart/h con cooldown.
|
||||||
|
|
||||||
|
Inoltre: monitoraggio Windows Event Log per fallimenti servizio `act_runner` (alert via
|
||||||
|
EventLog query schedulata o webhook).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Performance & throughput
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
Aggiungere a `CI-WinBuild.vmx`:
|
||||||
|
```ini
|
||||||
|
sharedFolder0.present = "TRUE"
|
||||||
|
sharedFolder0.hostPath = "F:\\CI\\Cache\\NuGet"
|
||||||
|
sharedFolder0.guestName = "nuget-cache"
|
||||||
|
```
|
||||||
|
Poi nello scriptblock di build:
|
||||||
|
```powershell
|
||||||
|
$env:NUGET_PACKAGES = '\\vmware-host\Shared Folders\nuget-cache'
|
||||||
|
```
|
||||||
|
Equivalente per pip:
|
||||||
|
```powershell
|
||||||
|
$env:PIP_CACHE_DIR = '\\vmware-host\Shared Folders\pip-cache'
|
||||||
|
```
|
||||||
|
Dopo lo snapshot refresh la cache va riscaldata una volta — accettabile.
|
||||||
|
|
||||||
|
### 3.2 [P1] Sostituire `Compress-Archive` con 7-Zip o robocopy
|
||||||
|
File: [scripts/Invoke-RemoteBuild.ps1:112,148,185](scripts/Invoke-RemoteBuild.ps1) (3 chiamate `Compress-Archive`).
|
||||||
|
Prerequisito: 7-Zip nel template — vedi §6.6 e sezione "In-VM Git Clone".
|
||||||
|
|
||||||
|
**Motivazione**: `Compress-Archive` è single-threaded e su repo medio-grandi (>100 MB sorgente)
|
||||||
|
può prendere 20-40 s.
|
||||||
|
|
||||||
|
**Alternative**:
|
||||||
|
- **7-Zip** (richiede installazione nel template — vedi sezione "In-VM Git Clone" §B):
|
||||||
|
```powershell
|
||||||
|
& 'C:\Program Files\7-Zip\7z.exe' a -mmt=on -mx1 $hostZip "$HostSourceDir\*"
|
||||||
|
```
|
||||||
|
Vantaggio: multi-thread, ratio simile con `-mx1`.
|
||||||
|
- **robocopy via admin share UNC** (`\\<vmIP>\C$\CI\build`): nessun zip/unzip, supporto delta,
|
||||||
|
ma richiede SMB aperto sulla VM — confliggente con §1.2.
|
||||||
|
|
||||||
|
Misurare con un repo reale: probabile risparmio 10-20 s/build su `nsis-plugin-nsinnounp`.
|
||||||
|
|
||||||
|
### 3.3 [P2] In-VM clone (`-UseGitClone`)
|
||||||
|
Implementazione disegnata di seguito (sezione "In-VM Git Clone (Ottimizzazione)").
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
Con build di 2 minuti, l'overhead è ~40% — vale lo sforzo. Con build di 10+ minuti, no.
|
||||||
|
|
||||||
|
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
|
||||||
|
File: [docs/OPTIMIZATION.md:82-89](docs/OPTIMIZATION.md), VMX template.
|
||||||
|
|
||||||
|
`numvcpus=4`, `memsize=6144` è ragionevole ma non ottimo per ogni build. Per
|
||||||
|
`nsis-plugin-nsinnounp` (4 build paralleli interni con thread divisi) 4 vCPU sono già saturati.
|
||||||
|
|
||||||
|
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
|
||||||
|
- [ ] 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
|
||||||
|
File: [scripts/Invoke-CIJob.ps1:178-187](scripts/Invoke-CIJob.ps1).
|
||||||
|
|
||||||
|
**Motivazione**: i log attuali sono `Write-Host` testuale. Per dashboard / alerting servono
|
||||||
|
log machine-readable.
|
||||||
|
|
||||||
|
Aggiungere a fianco del transcript un secondo file `invoke-ci.jsonl`:
|
||||||
|
```powershell
|
||||||
|
function Write-JobEvent {
|
||||||
|
param([string]$Phase, [string]$Status, [hashtable]$Data = @{})
|
||||||
|
$event = @{
|
||||||
|
ts = (Get-Date).ToString('o')
|
||||||
|
jobId = $JobId
|
||||||
|
phase = $Phase
|
||||||
|
status = $Status
|
||||||
|
data = $Data
|
||||||
|
}
|
||||||
|
$event | ConvertTo-Json -Compress | Add-Content $jsonLog
|
||||||
|
}
|
||||||
|
```
|
||||||
|
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
|
||||||
|
Generare `F:\CI\Metrics\runner.prom` da uno scheduled task ogni 60s:
|
||||||
|
```
|
||||||
|
ci_runner_orphan_vms 0
|
||||||
|
ci_runner_disk_free_gb 423
|
||||||
|
ci_runner_artifacts_total 247
|
||||||
|
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
|
||||||
|
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
|
||||||
|
File: nuovo `docs/RUNBOOK.md`.
|
||||||
|
|
||||||
|
Documentare con copy-pasta:
|
||||||
|
- "Runner offline in Gitea UI" → check service, log, restart.
|
||||||
|
- "Tutte le build falliscono in Phase 2" → `vmrun -T ws list`, parent VMDK lock, snapshot mancante.
|
||||||
|
- "Build lente" → check disk free, vmware-vmx.exe CPU, rete VMnet8.
|
||||||
|
- "VMX corrotto post-crash host" → restore from `F:\CI\Backups\Template_<latest>`.
|
||||||
|
|
||||||
|
Ogni voce: sintomo, comando di triage, fix, escalation.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Qualità codice & test
|
||||||
|
|
||||||
|
### 5.1 [P1] Pester smoke tests sugli script
|
||||||
|
File: nuovo `tests/` directory.
|
||||||
|
|
||||||
|
**Motivazione**: manca un livello di test sotto l'e2e.
|
||||||
|
|
||||||
|
Pester può coprire:
|
||||||
|
- `New-BuildVM` con vmrun mockato — verifica che il VMX path costruito sia ben formato
|
||||||
|
per JobId con caratteri speciali.
|
||||||
|
- `Wait-VMReady` con `Test-WSMan` mockato — verifica timeout, fasi, ritorni.
|
||||||
|
- `Remove-BuildVM` con `vmrun list` mockato — verifica retry deleteVM.
|
||||||
|
- Validazione IP regex (§1.4) — table tests.
|
||||||
|
|
||||||
|
Eseguire in CI tramite il runner stesso (workflow `lint.yml` esiste già — espandere).
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
- Risoluzione `vmrun.exe` (3 file).
|
||||||
|
- `Test-Path` + `New-Item` per dir lazy creation.
|
||||||
|
- Function `Invoke-VmrunCommand` con gestione `$LASTEXITCODE`.
|
||||||
|
|
||||||
|
Risultato: meno LoC, fix in un punto solo, più facile da testare.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
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
|
||||||
|
File: [gitea/workflows/lint.yml](gitea/workflows/lint.yml) (già esiste).
|
||||||
|
|
||||||
|
Aggiungere regole specifiche progetto (`PSScriptAnalyzerSettings.psd1` in root):
|
||||||
|
- `PSAvoidUsingPlainTextForPassword` — rilevante per [Setup-WinBuild2025.ps1:123](template/Setup-WinBuild2025.ps1:123) (`[string] $BuildPassword` → `ConvertTo-SecureString -AsPlainText` riga 253).
|
||||||
|
- `PSAvoidUsingInvokeExpression`.
|
||||||
|
- `PSUseShouldProcessForStateChangingFunctions`.
|
||||||
|
- Regole custom: vietare hardcoded `F:\CI\` fuori da config (forzare param/env).
|
||||||
|
|
||||||
|
### 5.5 [P3] Type hints nei param block
|
||||||
|
Già parzialmente fatto. Estendere a tutti gli script per migliorare IntelliSense e
|
||||||
|
validazione runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Scalabilità & estensioni
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
Vedi anche sezione "Linux Build VM" sotto per il riassunto inline.
|
||||||
|
|
||||||
|
**Considerazione architetturale**: SSH su WSL2 non basta — serve VM vera per parità con il
|
||||||
|
flusso Windows. Ubuntu 24.04 minimal con script bash diretto (parità con flusso Windows).
|
||||||
|
Sostituire WinRM con SSH key-based; `Invoke-Command` non funziona — usare `ssh.exe` +
|
||||||
|
`scp.exe` o il modulo `Posh-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)
|
||||||
|
File: [gitea/workflow-example.yml](gitea/workflow-example.yml).
|
||||||
|
|
||||||
|
Trasformare in *composite action* Gitea (`.gitea/actions/local-ci-build/action.yml`)
|
||||||
|
richiamabile da qualsiasi repo:
|
||||||
|
```yaml
|
||||||
|
- uses: ./Simone/local-ci-build@v1
|
||||||
|
with:
|
||||||
|
build-command: 'python build_plugin.py --final'
|
||||||
|
artifact-source: 'dist'
|
||||||
|
submodules: true
|
||||||
|
```
|
||||||
|
Riduce la duplicazione su nuovi repo e centralizza l'evoluzione del wrapper.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
File: [gitea/workflows/build-nsis.yml](gitea/workflows/build-nsis.yml).
|
||||||
|
|
||||||
|
Attualmente single job. Quando arriverà la VM Linux:
|
||||||
|
```yaml
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [windows, linux]
|
||||||
|
runs-on: ${{ matrix.target }}-build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
File: [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||||
|
|
||||||
|
**Motivazione**: toolchain attuale (`.NET SDK`, `Python`, `VS Build Tools`) copre solo C#/Python/MSBuild.
|
||||||
|
Aggiungere tools generici-CI riduce il bisogno di installazione ad-hoc nei workflow e abilita
|
||||||
|
build più ampi senza toccare il template.
|
||||||
|
|
||||||
|
Tool da aggiungere come step `Setup-WinBuild2025` (ognuno con `Assert-Step`):
|
||||||
|
|
||||||
|
| Tool | Versione | Razionale |
|
||||||
|
| ------------------- | ----------- | ------------------------------------------------------------ |
|
||||||
|
| **Git for Windows** | latest LTS | Self-clone in VM (alt a host-zip-transfer), submodule fetch |
|
||||||
|
| **7-Zip** | latest | Universale unpack/zip; molti installer e workflow ne dipendono |
|
||||||
|
| **PowerShell 7.x** | latest LTS | Più veloce di 5.1, parallel pipelines, operatori moderni |
|
||||||
|
| **NSIS** | latest | Test build installer (es. `nsis-plugin-nsinnounp`) |
|
||||||
|
| **CMake** | latest | Build system cross-platform per progetti C/C++ nativi |
|
||||||
|
| **Node.js LTS** | latest LTS | Frontend/Electron pipelines, JS toolchain (npm) |
|
||||||
|
| **WiX Toolset** | v4 stable | MSI building da .NET projects |
|
||||||
|
| **gh CLI** | latest | Upload artifact a release Gitea/GitHub, comment PR |
|
||||||
|
| **Sysinternals** | sysinternals.com `live` | Diagnostica process/handle quando build flaky |
|
||||||
|
| **vcpkg** | latest | Package manager C++ per dipendenze native |
|
||||||
|
|
||||||
|
**Approccio**:
|
||||||
|
- Pinning hash SHA256 per ogni installer (vedi §1.3)
|
||||||
|
- Convenzione path guest: tool in `C:\BuildTools\<tool>\` (separato da `C:\CI\` runtime
|
||||||
|
— `C:\CI\` resta per artefatti job e worker temp; `C:\BuildTools\` per software statico).
|
||||||
|
Documentare in `docs/BEST-PRACTICES.md` prima di implementare.
|
||||||
|
- Aggiungere a `PATH` machine-wide
|
||||||
|
- Defender già off → no scan overhead durante install
|
||||||
|
- Step esegue in seriale dopo `Toolchain cross-check` esistente, prima di `Cleanup`
|
||||||
|
- `Final pre-snapshot gate`: aggiungere check `where.exe` per ogni tool
|
||||||
|
- Cross-link: Git for Windows e 7-Zip sono richiesti anche da §3.2 e dalla sezione
|
||||||
|
"In-VM Git Clone" — implementare qui per evitare doppio lavoro
|
||||||
|
|
||||||
|
**Validazione per-tool**:
|
||||||
|
- Eseguibile risolvibile via `where.exe`
|
||||||
|
- Versione exact match al param `-<Tool>Version`
|
||||||
|
- (per gh) `gh --version` returns 0
|
||||||
|
- (per vcpkg) `vcpkg.exe version`
|
||||||
|
|
||||||
|
**Param da aggiungere** a Setup + Prepare:
|
||||||
|
- `-GitVersion`, `-7ZipVersion`, `-Pwsh7Version`, `-NSISVersion`, `-CMakeVersion`,
|
||||||
|
`-NodeJSVersion`, `-WiXVersion`, `-GhCliVersion`, `-VcpkgRev` (rev SHA del repo)
|
||||||
|
|
||||||
|
**Tier-2 (opzionali, separati)**: Java JDK, Maven/Gradle, Rust, Go, LLVM, Docker, Azure/AWS CLI, WinDbg.
|
||||||
|
Non includere in Setup base — workflow-level via `choco install` o download diretto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Refactor confine Deploy ↔ Setup
|
||||||
|
|
||||||
|
**Obiettivo**: separazione netta delle responsabilità — Deploy produce **template OS**
|
||||||
|
stand-alone, Setup fa **build customization** (user, dirs, toolchain). Eliminare drift e
|
||||||
|
duplicazioni; ogni concern ha **una sola** sorgente di verità.
|
||||||
|
|
||||||
|
**Stato attuale (duplicati)**: UAC, Explorer LaunchTo, Server Manager off, DisableCAD,
|
||||||
|
OOBE telemetry, WU services disable — entrambi gli script li toccano.
|
||||||
|
|
||||||
|
### 7.1 [P1] Spostare base OS hardening da Setup a Deploy
|
||||||
|
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
|
||||||
|
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||||
|
|
||||||
|
**Da MUOVERE Setup → Deploy**:
|
||||||
|
|
||||||
|
| Concern Setup | Step attuale Setup | Azione Deploy |
|
||||||
|
| ----------------------------- | ------------------ | ------------------------------------------- |
|
||||||
|
| Firewall off all profiles | Step 1 | Aggiungere `Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False` in post-install (ora Deploy aggiunge solo regole RDP/ICMP) |
|
||||||
|
| WinRM tuning memoria/timeout | Step 3 (parziale) | Aggiungere `MaxMemoryPerShellMB=2048`, `IdleTimeOut=7200000`, `MaxProcessesPerShell=25` in post-install dopo Enable-PSRemoting |
|
||||||
|
|
||||||
|
**Da CONVERTIRE in Setup a Assert-Step (validation-only)**:
|
||||||
|
|
||||||
|
| Step Setup | Diventa |
|
||||||
|
| ---------- | ------- |
|
||||||
|
| Step 1 Firewall | `Assert-Step` 3 profili Enabled=False (richiede prima la riga "Da MUOVERE" → Deploy: Set-NetFirewallProfile -Enabled False) |
|
||||||
|
| Step 2 Defender | Già validation-only (refactor 2026-05-09) |
|
||||||
|
| Step 3 WinRM tuning | `Assert-Step` MaxMemoryPerShellMB, IdleTimeOut, AllowUnencrypted, Basic |
|
||||||
|
| Step 4b UAC | `Assert-Step` EnableLUA=0, LocalAccountTokenFilterPolicy=1 |
|
||||||
|
| Step 5b Explorer LaunchTo | `Assert-Step` HKCU LaunchTo=1 |
|
||||||
|
| Step 5c Server Manager / DisableCAD / OOBE | `Assert-Step` HKLM machine policy + DisableCAD=1 + OOBE policy |
|
||||||
|
|
||||||
|
**Da MANTENERE in Setup (build env, non base OS)**:
|
||||||
|
- Step 4: build user `ci_build` + admin
|
||||||
|
- Step 5: `C:\CI\{build,output,scripts}` dirs
|
||||||
|
- Step 7-9: .NET SDK, Python, VS Build Tools
|
||||||
|
- Step 10: Toolchain cross-check
|
||||||
|
- Cleanup, Final pre-snapshot gate
|
||||||
|
|
||||||
|
**Test acceptance**:
|
||||||
|
- Deploy stand-alone produce VM con tutte le validation Setup che passano (no Setup run)
|
||||||
|
- Setup run dopo Deploy: tutte Assert-Step pass `[OK]` immediato (no work)
|
||||||
|
- Test `--Skip` orthogonali (no regressione)
|
||||||
|
|
||||||
|
### 7.2 [P1] WU lifecycle — strategia B (Deploy: GPO + Manual; Setup: lifecycle)
|
||||||
|
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
|
||||||
|
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||||
|
|
||||||
|
**Stato attuale (conflitto ordering)**:
|
||||||
|
- Deploy: `sc.exe config wuauserv start= disabled` + `sc.exe stop wuauserv` (idem UsoSvc, WaaSMedicSvc) + GPO `NoAutoUpdate=1`
|
||||||
|
- Setup Step 6: necessita servizi UP per SchTask SYSTEM run WU
|
||||||
|
- Setup Step 6b: re-disabilita servizi → doppio lavoro, fragile
|
||||||
|
|
||||||
|
**Strategia B — separazione pulita**:
|
||||||
|
|
||||||
|
**Deploy** (post-install.ps1):
|
||||||
|
- Lascia servizi WU `start=demand` (Manual, default Windows) — NON `disabled`, NON `stop`
|
||||||
|
- Setta solo GPO `NoAutoUpdate=1`, `DisableWindowsUpdateAccess=1` → no background scheduled WU
|
||||||
|
- Risultato: WU è invocabile on-demand ma non parte mai da solo
|
||||||
|
|
||||||
|
**Setup** (Step 6 / 6b):
|
||||||
|
- Step 6 (skip se `-SkipWindowsUpdate`): `sc.exe config wuauserv start= demand` (idempotent), avvia SchTask SYSTEM, attende exit code, parse 0/2/3/3010
|
||||||
|
- Step 6b (sempre, anche con `-Skip`): `sc.exe config wuauserv start= disabled` + `sc.exe stop wuauserv` (idem UsoSvc, WaaSMedicSvc), conferma GPO presenti — snapshot capture WU permanently off
|
||||||
|
|
||||||
|
**Pattern run periodico patches** (allineato §2.5 snapshot versionato):
|
||||||
|
- Re-deploy template `BaseClean_<yyyyMMdd>` ogni N mesi via Deploy + Prepare (no `-Skip`) → patch applicate al template fresh
|
||||||
|
|
||||||
|
**Validazione finale** (Setup Final gate):
|
||||||
|
- `wuauserv.StartType == Disabled`
|
||||||
|
- `UsoSvc.StartType == Disabled`
|
||||||
|
- GPO `NoAutoUpdate=1`, `DisableWindowsUpdateAccess=1`
|
||||||
|
|
||||||
|
**Test acceptance**:
|
||||||
|
- Deploy stand-alone: `Get-Service wuauserv | Select StartType` → `Manual`, `Get-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate` → 1
|
||||||
|
- Setup `-SkipWindowsUpdate`: Final gate vede `StartType=Disabled` (impostato da Step 6b)
|
||||||
|
- Setup senza `-Skip`: WU runs, exit 0/2/3, poi servizi disabled
|
||||||
|
|
||||||
|
### 7.3 [P2] Update header docs di entrambi gli script
|
||||||
|
- [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1) `.DESCRIPTION`:
|
||||||
|
enumerare tutte le hardening voci che Deploy ora possiede (firewall, WinRM tuning,
|
||||||
|
WU GPO non disable)
|
||||||
|
- [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1) `.DESCRIPTION`:
|
||||||
|
marcare Step 1/3/4b/5b/5c come "validation-only — set by Deploy"
|
||||||
|
- [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md): aggiornare descrizione
|
||||||
|
"Cosa fa Setup-WinBuild2025" — riflettere nuovo confine
|
||||||
|
|
||||||
|
### 7.4 [P2] Test e2e refactor
|
||||||
|
- Run Deploy stand-alone su VM test → verifica tutte le hardening voci presenti
|
||||||
|
(`reg query` per chiavi UAC/OOBE/Server Manager, `Get-NetFirewallProfile`, `Get-Service WMUauserv`)
|
||||||
|
- Run Prepare-WinBuild2025 dopo Deploy → tutti `Assert-Step` pass `[OK]` senza work
|
||||||
|
- Run Prepare con `-SkipWindowsUpdate` → comportamento invariato
|
||||||
|
- Run Prepare senza `-Skip` → WU runs, services lifecycled correttamente
|
||||||
|
|
||||||
|
### 7.5 [P3] Rinomina Setup → Setup-CITools (opzionale, futuro)
|
||||||
|
Una volta che Setup fa solo build customization, nome `Setup-WinBuild2025` è impreciso —
|
||||||
|
non setup-a la build VM, **estende** un template OS già pronto con toolchain CI.
|
||||||
|
Rinominare in `Setup-CITools.ps1` o `Install-CIToolchain.ps1` rifletterebbe meglio la
|
||||||
|
responsabilità ridotta. Tracciare in TODO post-7.1/7.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## In-VM Git Clone (Ottimizzazione) — riferimento §3.3
|
||||||
|
|
||||||
Sfruttare l'accesso internet della VM (VMnet8 NAT) per fare il `git clone` direttamente
|
Sfruttare l'accesso internet della VM (VMnet8 NAT) per fare il `git clone` direttamente
|
||||||
nella VM, eliminando il ciclo host-clone → zip → WinRM-transfer → unzip.
|
nella VM, eliminando il ciclo host-clone → zip → WinRM-transfer → unzip.
|
||||||
@@ -141,7 +729,7 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
|
|||||||
**Punti chiave:**
|
**Punti chiave:**
|
||||||
- Il PAT **non deve essere nello snapshot** — viene iniettato a runtime come env var via WinRM
|
- Il PAT **non deve essere nello snapshot** — viene iniettato a runtime come env var via WinRM
|
||||||
(`git clone https://Simone:<pat>@gitea.emulab.it/<repo>.git`) e non viene mai persistito
|
(`git clone https://Simone:<pat>@gitea.emulab.it/<repo>.git`) e non viene mai persistito
|
||||||
- Git deve essere installato nel template (`Setup-TemplateVM.ps1`) — ora è assente per design
|
- Git deve essere installato nel template (`Setup-WinBuild2025.ps1`) — ora è assente per design
|
||||||
- L'implementazione deve essere **non-breaking**: nuovo switch opt-in `-UseGitClone` in `Invoke-CIJob.ps1`
|
- L'implementazione deve essere **non-breaking**: nuovo switch opt-in `-UseGitClone` in `Invoke-CIJob.ps1`
|
||||||
- Si perde il parallelismo parziale (clone host avviene mentre VM boota), ma si elimina
|
- Si perde il parallelismo parziale (clone host avviene mentre VM boota), ma si elimina
|
||||||
tutto l'overhead di zip/transfer (rilevante su repo grandi o con molti submoduli)
|
tutto l'overhead di zip/transfer (rilevante su repo grandi o con molti submoduli)
|
||||||
@@ -149,28 +737,18 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
|
|||||||
|
|
||||||
### Task
|
### Task
|
||||||
|
|
||||||
- [ ] **Setup-TemplateVM.ps1 — installare Git for Windows**
|
- [ ] **Tool prerequisiti nel template** — implementati in §6.6 (Tier-1 Toolchain):
|
||||||
- [ ] Scaricare e installare Git for Windows (installer silenzioso da git-scm.com)
|
- Git for Windows + 7-Zip + gh CLI installati con hash pinning e `Assert-Step`
|
||||||
```powershell
|
- Rimuovere il commento "Git is NOT installed" dal docblock di Setup-WinBuild2025.ps1
|
||||||
$gitUrl = 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/Git-2.47.1-64-bit.exe'
|
una volta applicato §6.6
|
||||||
Start-Process $gitUrl '/VERYSILENT /NORESTART /COMPONENTS=gitlfs' -Wait
|
- curl.exe: già presente in Windows Server 2025 — verificare con `where.exe curl`
|
||||||
```
|
|
||||||
- [ ] Aggiungere `C:\Program Files\Git\cmd` al PATH di sistema nella VM
|
|
||||||
- [ ] Verificare: `git --version` ritorna exit 0 nella VM
|
|
||||||
- [ ] Rimuovere il commento "Git is NOT installed" dal docblock di Setup-TemplateVM.ps1
|
|
||||||
|
|
||||||
- [ ] **Setup-TemplateVM.ps1 — altri tool utili**
|
|
||||||
- [ ] **7-Zip**: installer silenzioso — molto più veloce di `Compress-Archive`/`Expand-Archive`
|
|
||||||
per archivi grandi (es. npm/NuGet cache warm)
|
|
||||||
- [ ] **curl.exe**: già presente in Windows 11/Server 2025 (v7.x) — verificare disponibilità
|
|
||||||
- [ ] Valutare: `jq` (parsing JSON in script PowerShell o bash) — opzionale
|
|
||||||
|
|
||||||
- [ ] **Invoke-CIJob.ps1 — aggiungere switch `-UseGitClone`**
|
- [ ] **Invoke-CIJob.ps1 — aggiungere switch `-UseGitClone`**
|
||||||
- [ ] Quando `-UseGitClone` è attivo:
|
- [ ] Quando `-UseGitClone` è attivo:
|
||||||
- Saltare Phase 1 (host git clone) e la creazione del `HostSourceDir` temporaneo
|
- Saltare Phase 1 (host git clone) e la creazione del `HostSourceDir` temporaneo
|
||||||
- Dopo Wait-VMReady, leggere PAT da Windows Credential Manager sull'host
|
- Dopo Wait-VMReady, leggere PAT da Windows Credential Manager sull'host
|
||||||
(es. target `git:https://gitea.emulab.it/...` o un target dedicato CI)
|
(es. target `git:https://gitea.emulab.it/...` o un target dedicato CI)
|
||||||
- Passare PAT come parametro a `Invoke-RemoteBuild.ps1` (mai loggato/stampato)
|
- Passare PAT a `Invoke-RemoteBuild.ps1` rispettando i vincoli sicurezza di §1.5
|
||||||
- [ ] Comportamento default invariato (`-UseGitClone` = `$false`) — nessuna regressione
|
- [ ] Comportamento default invariato (`-UseGitClone` = `$false`) — nessuna regressione
|
||||||
|
|
||||||
- [ ] **Invoke-RemoteBuild.ps1 — modalità in-VM clone**
|
- [ ] **Invoke-RemoteBuild.ps1 — modalità in-VM clone**
|
||||||
@@ -183,8 +761,8 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
|
|||||||
C:\CI\build
|
C:\CI\build
|
||||||
git -C C:\CI\build checkout $CloneCommit # pin al commit esatto
|
git -C C:\CI\build checkout $CloneCommit # pin al commit esatto
|
||||||
```
|
```
|
||||||
- [ ] Iniettare PAT come variabile d'ambiente della sessione WinRM (non argv, non log)
|
- [ ] Implementare iniezione/pulizia PAT secondo §1.5 (env var sessione, no argv, no log,
|
||||||
- [ ] Pulire la variabile d'ambiente dopo il clone: `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`
|
cleanup `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`)
|
||||||
|
|
||||||
- [ ] **Test e2e con `-UseGitClone`**
|
- [ ] **Test e2e con `-UseGitClone`**
|
||||||
- [ ] Aggiornare snapshot `BaseClean` con Git installato
|
- [ ] Aggiornare snapshot `BaseClean` con Git installato
|
||||||
@@ -194,7 +772,7 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Linux Build VM (Future)
|
## Linux Build VM (Future) — riferimento §6.1
|
||||||
|
|
||||||
Aggiungere supporto per build su Linux per testare la compilazione cross-platform
|
Aggiungere supporto per build su Linux per testare la compilazione cross-platform
|
||||||
(es. versione Linux del plugin o build check con GCC/Clang).
|
(es. versione Linux del plugin o build check con GCC/Clang).
|
||||||
@@ -210,9 +788,10 @@ Aggiungere supporto per build su Linux per testare la compilazione cross-platfor
|
|||||||
- [ ] Aggiungere parametro `-GuestOS` (`Windows` / `Linux`) o rilevamento automatico da VMX
|
- [ ] Aggiungere parametro `-GuestOS` (`Windows` / `Linux`) o rilevamento automatico da VMX
|
||||||
- [ ] Sostituire WinRM con SSH + SCP per trasferimento zip e raccolta artifacts
|
- [ ] Sostituire WinRM con SSH + SCP per trasferimento zip e raccolta artifacts
|
||||||
- [ ] Aggiungere supporto a comandi Linux (`bash`, `make`, `cmake --build`)
|
- [ ] Aggiungere supporto a comandi Linux (`bash`, `make`, `cmake --build`)
|
||||||
|
- [ ] Astrarre transport in `scripts/_Transport.psm1` (vedi §6.1)
|
||||||
|
|
||||||
- [ ] **Workflow Gitea — build matrix**
|
- [ ] **Workflow Gitea — build matrix**
|
||||||
- [ ] Definire matrix strategy `[windows-build, linux-build]` nel workflow
|
- [ ] Definire matrix strategy `[windows-build, linux-build]` nel workflow (vedi §6.4)
|
||||||
- [ ] Aggiungere label `linux-build` al runner (o registrare runner separato se necessario)
|
- [ ] Aggiungere label `linux-build` al runner (o registrare runner separato se necessario)
|
||||||
- [ ] Verificare che artifacts da entrambe le piattaforme vengano raccolti correttamente
|
- [ ] Verificare che artifacts da entrambe le piattaforme vengano raccolti correttamente
|
||||||
|
|
||||||
@@ -222,13 +801,19 @@ Aggiungere supporto per build su Linux per testare la compilazione cross-platfor
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Hardening (Post-MVP)
|
## Suggerimento di sequencing
|
||||||
|
|
||||||
- [ ] Passare WinRM da HTTP (5985) a HTTPS (5986) con certificato self-signed
|
Sprint progressivi per ridurre rischio / ottenere valore prima:
|
||||||
- [ ] Rimuovere `AllowUnencrypted=true` dalla config WinRM dopo migrazione HTTPS
|
|
||||||
- [ ] Verificare che Get-StoredCredential funzioni correttamente negli script
|
1. **Sprint 1 (sicurezza)** — §1.1, §1.2, §1.3, §1.4 — superficie di attacco e bug noti.
|
||||||
- [ ] Regole firewall — limitare WinRM alla subnet build VM (192.168.79.0/24 — VMnet8 NAT)
|
2. **Sprint 2 (operatività)** — §2.1, §2.2, §2.3, §2.5 — rendere il sistema "fire and forget".
|
||||||
- [ ] Rotazione password guest VM trimestralmente
|
3. **Sprint 3 (osservabilità)** — §4.1, §4.3, §4.4 — visibilità prima di estendere.
|
||||||
|
4. **Sprint 4 (perf)** — §3.1, §3.2 misurati su un repo reale (baseline §3.6).
|
||||||
|
5. **Sprint 5 (qualità)** — §5.1, §5.2 — rifattorizzazione con safety net di test.
|
||||||
|
6. **Sprint 6+ (estensioni)** — Linux VM, workflow generico/composite action, federation.
|
||||||
|
|
||||||
|
Tenere ogni sprint piccolo: 1-2 voci alla volta, validare e2e dopo ognuna prima di passare
|
||||||
|
alla successiva.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -244,6 +829,7 @@ Aggiungere supporto per build su Linux per testare la compilazione cross-platfor
|
|||||||
| Snapshot name | `BaseClean` |
|
| Snapshot name | `BaseClean` |
|
||||||
| Clone base dir | `F:\CI\BuildVMs\` |
|
| Clone base dir | `F:\CI\BuildVMs\` |
|
||||||
| Artifact dir | `F:\CI\Artifacts\` |
|
| Artifact dir | `F:\CI\Artifacts\` |
|
||||||
|
| Log dir | `F:\CI\Logs\` (retention: 30 giorni) |
|
||||||
| Gitea URL (LAN) | `http://10.10.20.11:3100` |
|
| Gitea URL (LAN) | `http://10.10.20.11:3100` |
|
||||||
| Gitea URL (ext) | `https://gitea.emulab.it` |
|
| Gitea URL (ext) | `https://gitea.emulab.it` |
|
||||||
| Runner name | `local-windows-runner` (ID: 1) |
|
| Runner name | `local-windows-runner` (ID: 1) |
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ The runner host **never executes build tools directly**. Its only role is orches
|
|||||||
- Visual Studio Build Tools **2026** (MSBuild 18.5.4 / toolset v145)
|
- Visual Studio Build Tools **2026** (MSBuild 18.5.4 / toolset v145)
|
||||||
- .NET SDK **10.0.203**
|
- .NET SDK **10.0.203**
|
||||||
- Python **3.13.3** (per build script personalizzati come `build_plugin.py`)
|
- Python **3.13.3** (per build script personalizzati come `build_plugin.py`)
|
||||||
- Git (per repo clone inside VM)
|
|
||||||
- NuGet CLI (optional, dotnet restore covers most cases)
|
- NuGet CLI (optional, dotnet restore covers most cases)
|
||||||
|
- Git: NOT installed by default — source arrives via WinRM zip transfer. See TODO.md `-UseGitClone` for the in-VM git clone opt-in path.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -191,4 +191,4 @@ VMs have internet access via NAT — required for pip/nuget during build.
|
|||||||
- WinRM credentials are stored in Windows Credential Manager on host (not hardcoded)
|
- WinRM credentials are stored in Windows Credential Manager on host (not hardcoded)
|
||||||
- Each VM is fully destroyed after the job — no state carries between builds
|
- Each VM is fully destroyed after the job — no state carries between builds
|
||||||
- Runner host does not expose any ports to the build VM network
|
- Runner host does not expose any ports to the build VM network
|
||||||
- Source code is transferred via zip (Compress-Archive → WinRM copy → Expand-Archive in guest) — no git clone inside VM
|
- Source code is transferred via zip (Compress-Archive → WinRM copy → Expand-Archive in guest) — no PAT is ever present inside the VM; VMs have NAT internet for pip/nuget at build time
|
||||||
|
|||||||
+14
-10
@@ -233,27 +233,31 @@ Get-EventLog -LogName Application -Source '*runner*' -Newest 20
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Network Isolation Verification
|
## 8. Network Topology Verification
|
||||||
|
|
||||||
After setting up the host-only VMware network, verify that build VMs cannot
|
Build VMs run on **VMnet8 (NAT)** — they have internet access, which is required
|
||||||
reach the internet (important for supply-chain security):
|
for pip/nuget package downloads at build time. Verify the expected topology:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# From inside a build VM via WinRM:
|
# From inside a build VM via WinRM — confirm NAT internet is reachable:
|
||||||
Invoke-Command -Session $session -ScriptBlock {
|
Invoke-Command -Session $session -ScriptBlock {
|
||||||
$result = Test-Connection 8.8.8.8 -Count 1 -Quiet
|
$result = Test-Connection 8.8.8.8 -Count 1 -Quiet
|
||||||
if ($result) {
|
if ($result) {
|
||||||
Write-Warning "VM has internet access — check VMware network adapter type"
|
Write-Host "VM has NAT internet access — expected for pip/nuget builds."
|
||||||
} else {
|
} else {
|
||||||
Write-Host "VM correctly isolated (no internet)"
|
Write-Warning "VM cannot reach internet — pip/nuget installs will fail. Check VMware NAT service."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Build VMs should only reach:
|
Build VMs can reach:
|
||||||
- The host (for WinRM connection)
|
- The host via VMnet8 gateway (WinRM on port 5985)
|
||||||
- Gitea server (for git clone, if reachable via host-only network)
|
- Internet via VMware NAT (for pip, nuget, npm at build time)
|
||||||
- NuGet cache share (host-side shared folder)
|
- Gitea server if on LAN reachable via NAT gateway
|
||||||
|
|
||||||
|
**Supply-chain note:** Source code is always injected by the host via WinRM zip
|
||||||
|
transfer — never cloned inside the VM using a PAT. This keeps credentials off
|
||||||
|
the VM even though the VM has outbound internet access.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# Host Setup — Local CI/CD
|
||||||
|
|
||||||
|
Procedura di bootstrap della macchina host Windows (i9-10900X, 64 GB RAM, NVMe).
|
||||||
|
Eseguire **una volta** prima di qualsiasi provisioning del template VM o esecuzione di job CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisiti host
|
||||||
|
|
||||||
|
| Componente | Versione/Note |
|
||||||
|
| ----------------------- | ----------------------------------------------------------------- |
|
||||||
|
| OS | Windows 11 (host fisico) o Windows Server 2022+ |
|
||||||
|
| VMware Workstation | Pro 17.x — necessario per `vmrun.exe` e supporto linked clone |
|
||||||
|
| Storage | NVMe dedicato (consigliato `F:\` riservato a CI, ≥ 500 GB libero) |
|
||||||
|
| RAM | ≥ 32 GB (4 VM parallele × 6-8 GB) |
|
||||||
|
| Rete | VMnet8 (NAT) configurato in VMware Workstation |
|
||||||
|
| PowerShell | 5.1+ (built-in) o 7.x |
|
||||||
|
| Privilegi | Account amministrativo per Setup-Host.ps1 |
|
||||||
|
|
||||||
|
Software opzionali installati on-demand dallo script:
|
||||||
|
- **NSSM** — installazione automatica via Chocolatey se presente; altrimenti install manuale da https://nssm.cc
|
||||||
|
- **CredentialManager** module — installato automaticamente (scope CurrentUser)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
`Setup-Host.ps1` crea l'intera struttura sotto `$CIRoot` (default `F:\CI\`):
|
||||||
|
|
||||||
|
```
|
||||||
|
F:\CI\
|
||||||
|
├── BuildVMs\ # cloni temporanei delle VM build (auto-cleanup post-job)
|
||||||
|
├── Artifacts\ # output build raccolti dall'host
|
||||||
|
├── Logs\ # log per-job (retention: 30 giorni)
|
||||||
|
├── Templates\
|
||||||
|
│ └── WinBuild\ # VMX template Windows (immutabile dopo snapshot)
|
||||||
|
├── act_runner\
|
||||||
|
│ ├── act_runner.exe # binario runner Gitea
|
||||||
|
│ ├── config.yaml # config runner (path, label, capacity)
|
||||||
|
│ └── logs\ # stdout/stderr del servizio
|
||||||
|
├── Cache\
|
||||||
|
│ └── NuGet\ # cache NuGet condivisa via shared folder (TODO §3.1)
|
||||||
|
├── RunnerWork\ # working dir di act_runner (job staging)
|
||||||
|
└── ISO\ # ISO di installazione (Windows Server 2025, ecc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Procedura — esecuzione
|
||||||
|
|
||||||
|
### Step 1 — Clonare il repository
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://gitea.emulab.it/Simone/Local-CI-CD-System.git N:\Code\Workspace\Local-CI-CD-System
|
||||||
|
cd N:\Code\Workspace\Local-CI-CD-System
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — Eseguire Setup-Host.ps1 (elevato)
|
||||||
|
|
||||||
|
Apri PowerShell **come Administrator**, poi:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Esecuzione minimale (crea dirs, archivia credenziali, salta install runner)
|
||||||
|
.\Setup-Host.ps1 -SkipRunnerInstall
|
||||||
|
|
||||||
|
# Setup completo con registrazione runner
|
||||||
|
.\Setup-Host.ps1 -GiteaRunnerToken 'abc123token'
|
||||||
|
|
||||||
|
# CI root su drive diverso
|
||||||
|
.\Setup-Host.ps1 -CIRoot 'D:\CI' -GiteaRunnerToken 'abc123token'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cosa fa Setup-Host.ps1 — passo per passo
|
||||||
|
|
||||||
|
1. **Crea albero directory** sotto `$CIRoot` (vedi layout sopra)
|
||||||
|
2. **Verifica VMware Workstation** — controlla presenza di `C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe`
|
||||||
|
3. **Installa modulo CredentialManager** (scope CurrentUser, se non presente)
|
||||||
|
4. **Archivia credenziali guest VM** in Windows Credential Manager (target `BuildVMGuest`)
|
||||||
|
- Se non passa `-GuestPassword`, prompt interattivo (SecureString)
|
||||||
|
- Se credenziale già esistente, non sovrascrive — istruzioni per update
|
||||||
|
5. **Copia `runner/config.yaml`** in `$CIRoot\act_runner\config.yaml` (se non esistente)
|
||||||
|
6. **Installa act_runner come servizio Windows** via NSSM:
|
||||||
|
- Verifica presenza NSSM in PATH; tenta install via `choco install nssm -y`
|
||||||
|
- Registra runner con Gitea (`act_runner register --no-interactive --instance ... --token ... --name local-windows-runner --labels windows-build:host,dotnet:host,msbuild:host`) se token fornito
|
||||||
|
- Crea servizio: `AppDirectory`, `AppParameters daemon --config <path>`, log rotation 10 MB
|
||||||
|
- Avvia servizio (`SERVICE_AUTO_START`)
|
||||||
|
7. **Configura SSH alias `gitea-ci`** in `~/.ssh/config` (HostName, Port, User git)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parametri principali
|
||||||
|
|
||||||
|
| Parametro | Default | Note |
|
||||||
|
| ---------------------------- | -------------------------------- | ------------------------------------- |
|
||||||
|
| `-CIRoot` | `F:\CI` | Base directory per tutti i dati CI |
|
||||||
|
| `-GuestCredentialTarget` | `BuildVMGuest` | Target Credential Manager |
|
||||||
|
| `-GuestUsername` | `ci_build` | Account build dentro la VM |
|
||||||
|
| `-GuestPassword` | (prompt) | Mai hard-codare |
|
||||||
|
| `-ActRunnerExe` | `<CIRoot>\act_runner\act_runner.exe` | Path binario runner |
|
||||||
|
| `-ActRunnerConfigYaml` | `<repo>\runner\config.yaml` | Source del config da copiare |
|
||||||
|
| `-GiteaUrl` | `http://10.10.20.11:3100` | URL Gitea per registrazione |
|
||||||
|
| `-GiteaRunnerToken` | (vuoto) | Token registrazione (Admin → Runners) |
|
||||||
|
| `-SkipRunnerInstall` | `$false` | Salta installazione servizio |
|
||||||
|
| `-SkipSSHConfig` | `$false` | Salta scrittura SSH alias |
|
||||||
|
| `-GiteaSSHHost` | `10.10.20.11` | Hostname SSH Gitea |
|
||||||
|
| `-GiteaSSHPort` | `2222` | Porta SSH Gitea |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-setup checklist
|
||||||
|
|
||||||
|
Allo `Setup-Host.ps1` exit 0:
|
||||||
|
|
||||||
|
1. **Posiziona binario act_runner** se non già fatto:
|
||||||
|
- Path: `F:\CI\act_runner\act_runner.exe`
|
||||||
|
- Download: https://gitea.com/gitea/act_runner/releases/tag/v1.0.2
|
||||||
|
|
||||||
|
2. **Posiziona ISO Windows Server 2025** in `F:\CI\ISO\`
|
||||||
|
(es. `26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso`)
|
||||||
|
|
||||||
|
3. **Configura host WinRM client** (necessario per Prepare-WinBuild2025.ps1):
|
||||||
|
```powershell
|
||||||
|
Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force
|
||||||
|
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`.
|
||||||
|
|
||||||
|
4. **Provisiona il template VM** — vedi [WINDOWS-TEMPLATE-SETUP.md](WINDOWS-TEMPLATE-SETUP.md)
|
||||||
|
|
||||||
|
5. **Registra chiave SSH con Gitea**:
|
||||||
|
- Gitea UI → User Settings → SSH Keys → Add Key
|
||||||
|
- Chiave pubblica: `%USERPROFILE%\.ssh\id_rsa.pub`
|
||||||
|
|
||||||
|
6. **Verifica runner online** in Gitea:
|
||||||
|
- `<GiteaUrl>/-/admin/runners`
|
||||||
|
- Status atteso: **Online**, label `windows-build:host,dotnet:host,msbuild:host`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference Paths
|
||||||
|
|
||||||
|
| Item | Path / Value |
|
||||||
|
| ------------------------- | ------------------------------------------------------------ |
|
||||||
|
| vmrun.exe | `C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe` |
|
||||||
|
| act_runner exe | `F:\CI\act_runner\act_runner.exe` (servizio: `act_runner`) |
|
||||||
|
| act_runner config | `F:\CI\act_runner\config.yaml` |
|
||||||
|
| act_runner logs | `F:\CI\act_runner\logs\` (stdout.log, stderr.log) |
|
||||||
|
| Template VMX | `F:\CI\Templates\WinBuild\CI-WinBuild.vmx` |
|
||||||
|
| Snapshot name | `BaseClean` |
|
||||||
|
| Clone base dir | `F:\CI\BuildVMs\` |
|
||||||
|
| Artifact dir | `F:\CI\Artifacts\` |
|
||||||
|
| Log dir | `F:\CI\Logs\` (retention: 30 giorni) |
|
||||||
|
| Gitea URL (LAN) | `http://10.10.20.11:3100` |
|
||||||
|
| Gitea URL (ext) | `https://gitea.emulab.it` |
|
||||||
|
| Runner name | `local-windows-runner` (ID: 1) |
|
||||||
|
| Build VM subnet | `192.168.79.0/24` (VMnet8 — NAT, internet access per build) |
|
||||||
|
| Credential Manager target | `BuildVMGuest` |
|
||||||
|
| Guest username | `ci_build` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance & operational tasks
|
||||||
|
|
||||||
|
Voci di backlog (vedi [TODO.md](../TODO.md)):
|
||||||
|
|
||||||
|
- **§2.2** Cleanup VM orfane schedulato (`scripts/Cleanup-OrphanedBuildVMs.ps1` ogni 6h, `-MaxAgeHours 4`)
|
||||||
|
- **§2.3** Retention artifact + log (Artifacts > 30 gg, Logs > 30 gg, guard libero < 50 GB)
|
||||||
|
- **§2.7** Health check runner (query API Gitea ogni 15 min, restart su offline)
|
||||||
|
- **§4.3** Disk space alert (`F:` < 50 GB → eventcreate/webhook)
|
||||||
|
- **§1.2** Restringere `TrustedHosts` da `*` a `192.168.79.*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Sintomo | Diagnosi / Fix |
|
||||||
|
| --------------------------------------- | -------------------------------------------------------------------- |
|
||||||
|
| `vmrun.exe NOT found` | Installa VMware Workstation Pro, riesegui |
|
||||||
|
| `NSSM not available` | Install manuale da https://nssm.cc, aggiungi a PATH, riesegui |
|
||||||
|
| Runner registration `exit != 0` | Verifica token Gitea + URL raggiungibile da host |
|
||||||
|
| Service `act_runner` non parte | Check `F:\CI\act_runner\logs\stderr.log` |
|
||||||
|
| `New-StoredCredential` fail | Verifica modulo CredentialManager importato + scope corretto |
|
||||||
|
| Job CI fallisce su `Test-WSMan` | TrustedHosts host non include subnet VM, AllowUnencrypted=$false |
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
# Linux Template VM Setup — DRAFT / TODO
|
||||||
|
|
||||||
|
> **Status**: bozza. Non implementato. Vedi [TODO.md §6.1](../TODO.md) (P2).
|
||||||
|
> Documento di pianificazione per estendere il sistema CI con build su Linux,
|
||||||
|
> in parallelo al template Windows già operativo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Aggiungere supporto a build su Linux per:
|
||||||
|
- Compilazione cross-platform (es. versione Linux di plugin C/C++, build check con GCC/Clang)
|
||||||
|
- Test su distro Linux per progetti che lo richiedono
|
||||||
|
- Build matrix `[windows, linux]` nei workflow Gitea
|
||||||
|
|
||||||
|
Architettura simmetrica al template Windows: VM template → snapshot `BaseClean-Linux` →
|
||||||
|
linked clone per ogni job → cleanup automatico.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisioni di design (da validare)
|
||||||
|
|
||||||
|
### Distro
|
||||||
|
|
||||||
|
| Opzione | Pro | Contro |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------ | -------------------------------------------- |
|
||||||
|
| **Ubuntu 24.04 LTS** | Toolchain recente, supporto LTS fino al 2029, cloud-init ottimo | Snap default (lento per CI) |
|
||||||
|
| Debian 12 | Stabile, leggero, no snap | Toolchain meno recente |
|
||||||
|
| Rocky Linux 9 / Alma 9 | RHEL-compat, glibc moderno, dnf | Meno comune in workflow CI tipici |
|
||||||
|
|
||||||
|
**Scelta proposta**: Ubuntu 24.04 LTS minimal. Rationale: maggior parità con runner GitHub
|
||||||
|
ufficiali (`ubuntu-latest` mappa a 22.04/24.04), toolchain recente out-of-box, cloud-init
|
||||||
|
maturo per provisioning automatico.
|
||||||
|
|
||||||
|
### Transport host → VM
|
||||||
|
|
||||||
|
| Opzione | Pro | Contro |
|
||||||
|
| ------------------------ | ------------------------------------------------------------------ | -------------------------------------------- |
|
||||||
|
| **SSH (chiave pubblica)**| Standard Linux, supporto nativo OpenSSH su Win11/Server 2025 | Diversa API rispetto WinRM (PSSession) |
|
||||||
|
| WinRM su Linux (PSCore) | Codice script unificato | Setup complesso, non standard, fragile |
|
||||||
|
|
||||||
|
**Scelta proposta**: SSH key-based con `ssh.exe` + `scp.exe` (built-in Windows 10+) o
|
||||||
|
modulo `Posh-SSH`. WinRM su Linux non è production-ready.
|
||||||
|
|
||||||
|
### Layer di astrazione
|
||||||
|
|
||||||
|
Aggiungere `scripts/_Transport.psm1` con interfaccia comune:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Pseudo-API
|
||||||
|
function Invoke-RemoteCommand {
|
||||||
|
param($Target, $ScriptBlock, $Args, [ValidateSet('WinRM','SSH')] $Transport)
|
||||||
|
}
|
||||||
|
function Copy-RemoteItem {
|
||||||
|
param($Source, $Destination, $Target, $Direction, $Transport)
|
||||||
|
}
|
||||||
|
function Test-RemoteReady {
|
||||||
|
param($Target, $TimeoutSec, $Transport)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementazioni:
|
||||||
|
- **WinRM** wrapper: `New-PSSession` + `Invoke-Command` + `Copy-Item -ToSession`
|
||||||
|
- **SSH** wrapper: `ssh.exe` + `scp.exe` con `-i $keyfile -o StrictHostKeyChecking=accept-new`
|
||||||
|
|
||||||
|
`Invoke-CIJob.ps1` riceve `-GuestOS Windows|Linux` (o auto-detect dal VMX `guestOS`),
|
||||||
|
sceglie transport.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specifiche VM proposte
|
||||||
|
|
||||||
|
| Parametro | Valore |
|
||||||
|
| ----------------------- | --------------------------------------------------------------- |
|
||||||
|
| OS | Ubuntu Server 24.04 LTS minimal |
|
||||||
|
| vCPU | 4 |
|
||||||
|
| RAM | 4096 MB (4 GB) — meno di Windows, footprint Linux più snello |
|
||||||
|
| Disco | 40 GB thin |
|
||||||
|
| NIC | VMnet8 (NAT) — internet per `apt` |
|
||||||
|
| VMX path | `F:\CI\Templates\LinuxBuild\LinuxBuild.vmx` |
|
||||||
|
| ISO | `ubuntu-24.04.x-live-server-amd64.iso` (in `F:\CI\ISO\`) |
|
||||||
|
| Snapshot name | `BaseClean-Linux` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO — Implementazione
|
||||||
|
|
||||||
|
### Pre-requisiti host
|
||||||
|
|
||||||
|
- [ ] **OpenSSH client** verificato presente su host (default Win11/Server 2025)
|
||||||
|
```powershell
|
||||||
|
Get-WindowsCapability -Online -Name OpenSSH.Client*
|
||||||
|
```
|
||||||
|
- [ ] **Genera SSH key dedicata** per CI (no passphrase, separata da chiavi utente):
|
||||||
|
```powershell
|
||||||
|
ssh-keygen -t ed25519 -f F:\CI\keys\ci_linux -N '""' -C 'ci-linux-runner'
|
||||||
|
```
|
||||||
|
- [ ] **Aggiungere a `Setup-Host.ps1`** uno step opzionale `-GenerateLinuxCIKey` che crea
|
||||||
|
la chiave e la archivia con permessi corretti (`F:\CI\keys\` con ACL ristretta)
|
||||||
|
|
||||||
|
### Fase A — Crea VM e installa Ubuntu
|
||||||
|
|
||||||
|
- [ ] Crea nuova VM in VMware Workstation (4 vCPU, 4 GB RAM, 40 GB thin)
|
||||||
|
- [ ] NIC: VMnet8 (NAT)
|
||||||
|
- [ ] Boot da ISO Ubuntu Server 24.04 LTS minimal
|
||||||
|
- [ ] Durante install scegli:
|
||||||
|
- [ ] **Minimal install** (no extra packages)
|
||||||
|
- [ ] OpenSSH server: **Yes**
|
||||||
|
- [ ] Username: `ci_build`
|
||||||
|
- [ ] Hostname: `ci-linux-template`
|
||||||
|
- [ ] LVM: opzionale (non necessario per template)
|
||||||
|
- [ ] Annota IP DHCP (`ip a`)
|
||||||
|
|
||||||
|
### Fase B — Provisioning iniziale (manuale, una volta)
|
||||||
|
|
||||||
|
Dentro la VM, come `ci_build`:
|
||||||
|
|
||||||
|
- [ ] **Disabilita swap permanente** (overhead I/O su NVMe condivisa):
|
||||||
|
```bash
|
||||||
|
sudo swapoff -a
|
||||||
|
sudo sed -i '/ swap / s/^/#/' /etc/fstab
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Disabilita servizi non necessari** (snap, motd-news, ecc.):
|
||||||
|
```bash
|
||||||
|
sudo systemctl disable --now snapd.service snapd.socket snapd.seeded.service 2>/dev/null
|
||||||
|
sudo systemctl disable --now motd-news.timer 2>/dev/null
|
||||||
|
sudo systemctl disable --now apt-daily.timer apt-daily-upgrade.timer
|
||||||
|
```
|
||||||
|
Rationale: snap e timer apt rallentano boot e mangiano CPU su VM efimere.
|
||||||
|
|
||||||
|
- [ ] **Sudo passwordless** per `ci_build` (parità con WinRM admin senza prompt UAC):
|
||||||
|
```bash
|
||||||
|
echo 'ci_build ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/90-ci_build
|
||||||
|
sudo chmod 440 /etc/sudoers.d/90-ci_build
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Authorized SSH key** dell'host CI:
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
# Incolla la chiave pubblica generata su host (F:\CI\keys\ci_linux.pub)
|
||||||
|
echo 'ssh-ed25519 AAAA... ci-linux-runner' >> ~/.ssh/authorized_keys
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **SSH hardening** per template (ma non così stretto da bloccare CI):
|
||||||
|
```bash
|
||||||
|
sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
|
||||||
|
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
||||||
|
sudo systemctl restart sshd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase C — Setup-LinuxTemplateVM.sh (script equivalente a Setup-WinBuild2025.ps1)
|
||||||
|
|
||||||
|
Da creare: `template/Setup-LinuxTemplateVM.sh` con `set -euo pipefail` + `assert_step()` helper.
|
||||||
|
|
||||||
|
- [ ] **Helper `assert_step`** (throw on fail, log su success):
|
||||||
|
```bash
|
||||||
|
assert_step() {
|
||||||
|
local step="$1"; local desc="$2"; shift 2
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
echo " [OK] [$step] $desc"
|
||||||
|
else
|
||||||
|
echo " [FAIL] [$step] $desc" >&2; exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1 — `apt update` + upgrade**
|
||||||
|
```bash
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq
|
||||||
|
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
|
||||||
|
assert_step 'apt' 'no errors' true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 — Toolchain build essentials**
|
||||||
|
```bash
|
||||||
|
sudo apt-get install -y -qq \
|
||||||
|
build-essential gcc g++ clang \
|
||||||
|
make cmake ninja-build \
|
||||||
|
pkg-config autoconf automake libtool \
|
||||||
|
git curl wget ca-certificates \
|
||||||
|
python3 python3-pip python3-venv \
|
||||||
|
unzip zip xz-utils
|
||||||
|
assert_step 'toolchain' 'gcc presente' command -v gcc
|
||||||
|
assert_step 'toolchain' 'clang presente' command -v clang
|
||||||
|
assert_step 'toolchain' 'cmake presente' command -v cmake
|
||||||
|
assert_step 'toolchain' 'python3 presente' command -v python3
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 — Toolchain extra opzionale**
|
||||||
|
- [ ] **mingw-w64** se serve cross-compile per Windows: `sudo apt-get install -y mingw-w64`
|
||||||
|
- [ ] **.NET SDK** (per parità Windows): script `dotnet-install.sh` con channel pinned
|
||||||
|
- [ ] **Node.js** se workflow lo richiede: nvm o nodesource repo
|
||||||
|
|
||||||
|
- [ ] **Step 4 — CI working directories**
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/ci/{build,output,scripts,cache}
|
||||||
|
sudo chown -R ci_build:ci_build /opt/ci
|
||||||
|
assert_step 'dirs' 'tutte presenti' test -d /opt/ci/build -a -d /opt/ci/output
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5 — Disabilita auto-update apt** (no surprise reboot/lock):
|
||||||
|
```bash
|
||||||
|
sudo apt-get remove -y unattended-upgrades 2>/dev/null
|
||||||
|
sudo systemctl mask apt-daily.service apt-daily-upgrade.service
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6 — Cleanup pre-snapshot**
|
||||||
|
```bash
|
||||||
|
sudo apt-get clean
|
||||||
|
sudo rm -rf /var/lib/apt/lists/*
|
||||||
|
sudo journalctl --vacuum-time=1d
|
||||||
|
sudo rm -rf /tmp/* /var/tmp/*
|
||||||
|
history -c && cat /dev/null > ~/.bash_history
|
||||||
|
# Zero free space (riduce dim VMDK dopo compact, opzionale)
|
||||||
|
# sudo dd if=/dev/zero of=/zero bs=1M; sudo rm /zero
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7 — Final pre-snapshot validation**
|
||||||
|
- SSH server abilitato e in ascolto su 22
|
||||||
|
- `sudo` passwordless funzionante per `ci_build`
|
||||||
|
- Tutti i tool elencati (`gcc --version`, `cmake --version`, ecc.) ritornano exit 0
|
||||||
|
- `/opt/ci/{build,output,scripts}` esistono con owner corretto
|
||||||
|
- No leftover `/tmp/*`, history vuota
|
||||||
|
|
||||||
|
### Fase D — Prepare-LinuxTemplateSetup.ps1 (orchestratore host-side)
|
||||||
|
|
||||||
|
Equivalente di `Prepare-WinBuild2025.ps1` per Linux. Usa SSH invece di WinRM.
|
||||||
|
|
||||||
|
- [ ] **Pre-flight host**:
|
||||||
|
- SSH key esiste (`F:\CI\keys\ci_linux`)
|
||||||
|
- `ssh.exe` e `scp.exe` raggiungibili
|
||||||
|
- IP target risponde a `Test-NetConnection -Port 22`
|
||||||
|
|
||||||
|
- [ ] **Copia script in VM**:
|
||||||
|
```powershell
|
||||||
|
scp -i F:\CI\keys\ci_linux -o StrictHostKeyChecking=accept-new `
|
||||||
|
template\Setup-LinuxTemplateVM.sh ci_build@${VMIPAddress}:/tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Esegui script in VM**:
|
||||||
|
```powershell
|
||||||
|
ssh -i F:\CI\keys\ci_linux ci_build@${VMIPAddress} `
|
||||||
|
"chmod +x /tmp/Setup-LinuxTemplateVM.sh && sudo /tmp/Setup-LinuxTemplateVM.sh"
|
||||||
|
```
|
||||||
|
Cattura stdout + exit code, parse `[OK]`/`[FAIL]` lines.
|
||||||
|
|
||||||
|
- [ ] **Post-setup remote validation** (parità Windows): query SSH per state checks:
|
||||||
|
- `systemctl is-active ssh` = active
|
||||||
|
- `id ci_build` ritorna gruppo + sudo
|
||||||
|
- `gcc --version`, `cmake --version`, `python3 --version` exit 0
|
||||||
|
- `test -d /opt/ci/build` exit 0
|
||||||
|
|
||||||
|
### Fase E — Snapshot
|
||||||
|
|
||||||
|
- [ ] Solo se Prepare exit 0 → power off VM:
|
||||||
|
```bash
|
||||||
|
sudo shutdown -h now
|
||||||
|
```
|
||||||
|
- [ ] Take snapshot in VMware Workstation: **`BaseClean-Linux`** (case-sensitive)
|
||||||
|
|
||||||
|
### Fase F — Adattamento script CI
|
||||||
|
|
||||||
|
- [ ] **`scripts/_Transport.psm1`** (nuovo modulo) con interfaccia comune
|
||||||
|
- [ ] **`scripts/Invoke-CIJob.ps1`** — aggiungere `-GuestOS Windows|Linux`, branching su transport
|
||||||
|
- [ ] **`scripts/Wait-VMReady.ps1`** — supporto SSH probe (`Test-NetConnection -Port 22` + `ssh -o ConnectTimeout=5 ...`)
|
||||||
|
- [ ] **`scripts/Invoke-RemoteBuild.ps1`** — branch SSH+scp per Linux
|
||||||
|
- [ ] **`scripts/Get-BuildArtifacts.ps1`** — `scp` per Linux invece di `Copy-Item -FromSession`
|
||||||
|
- [ ] **`scripts/New-BuildVM.ps1`** — already OS-agnostic (vmrun clone), verificare che `BaseClean-Linux` sia parametrizzabile
|
||||||
|
- [ ] **`scripts/Remove-BuildVM.ps1`** — already OS-agnostic
|
||||||
|
|
||||||
|
### Fase G — Workflow Gitea
|
||||||
|
|
||||||
|
- [ ] **Registrare label `linux-build`** sul runner (oppure secondo runner dedicato):
|
||||||
|
```yaml
|
||||||
|
# runner/config.yaml — aggiungere a labels esistenti
|
||||||
|
labels:
|
||||||
|
- 'windows-build:host'
|
||||||
|
- 'linux-build:host'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Workflow matrix**:
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
target: [windows, linux]
|
||||||
|
runs-on: ${{ matrix.target }}-build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: pwsh -File scripts/Invoke-CIJob.ps1 -GuestOS ${{ matrix.target }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fase H — Test e2e
|
||||||
|
|
||||||
|
- [ ] **Smoke test** clone + SSH ready:
|
||||||
|
```powershell
|
||||||
|
.\scripts\New-BuildVM.ps1 -JobId 'linux-smoke' -SnapshotName 'BaseClean-Linux'
|
||||||
|
.\scripts\Wait-VMReady.ps1 -JobId 'linux-smoke' -Transport SSH
|
||||||
|
.\scripts\Remove-BuildVM.ps1 -JobId 'linux-smoke'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Build reale**: scegliere repo con build Linux nativo (es. `cmake` based) e
|
||||||
|
eseguire job e2e via `Invoke-CIJob.ps1 -GuestOS Linux`
|
||||||
|
|
||||||
|
- [ ] **Confronto artifact**: build dello stesso repo target Windows vs Linux,
|
||||||
|
verifica che entrambi vengano raccolti in `F:\CI\Artifacts\<jobId>\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Differenze chiave rispetto al template Windows
|
||||||
|
|
||||||
|
| Aspetto | Windows | Linux |
|
||||||
|
| ----------------------- | -------------------------------------- | ------------------------------------------- |
|
||||||
|
| Transport | WinRM HTTP/5985 + Basic | 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'` |
|
||||||
|
| AV/Firewall disable | Defender + Win Firewall off | ufw inattivo (default), no AV |
|
||||||
|
| Update package | Windows Update (COM API + sched task) | `apt-get update && upgrade` non-interactive |
|
||||||
|
| Toolchain | VS Build Tools 2026 + .NET SDK | gcc/g++/clang + cmake (+ .NET SDK opt) |
|
||||||
|
| Snapshot name | `BaseClean` | `BaseClean-Linux` |
|
||||||
|
| Footprint VM | 6 GB RAM, 80 GB disk | 4 GB RAM, 40 GB disk |
|
||||||
|
| Boot time | ~30-60 s post-clone | ~10-20 s post-clone (atteso) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog correlato (TODO.md)
|
||||||
|
|
||||||
|
- **§6.1 [P2]** Linux Build VM — entry parent
|
||||||
|
- **§6.4 [P3]** Build matrix `[windows, linux]` nel workflow
|
||||||
|
- **§6.3 [P3]** Multi-host runner federation (se 4 VM Linux + 4 Windows non bastano)
|
||||||
|
- **§5.2 [P2]** Modulo `_Transport.psm1` condiviso (prerequisito architetturale)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domande aperte
|
||||||
|
|
||||||
|
1. **.NET SDK su Linux** — sempre installato o solo se workflow lo richiede?
|
||||||
|
Decisione: installare **se** workload richiede parità di build. Default off, opt-in via flag.
|
||||||
|
2. **Image size** — Ubuntu minimal + toolchain ≈ 5 GB. Compact post-snapshot per ridurre footprint VMDK?
|
||||||
|
Decisione: sì, `dd if=/dev/zero` + `vmware-vdiskmanager -k` (manuale, una tantum).
|
||||||
|
3. **Pre-warm pool Linux** — boot Linux è veloce, beneficio minore. Decisione: implementare solo se profile dimostra > 30% overhead.
|
||||||
|
4. **Cloud-init vs script manuale** — cloud-init richiede ISO seed o NoCloud datasource. Più pulito ma overhead di setup. Decisione: script bash diretto (parità con flusso Windows). Cloud-init valutabile in v2.
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
# Windows Template VM Setup
|
||||||
|
|
||||||
|
Procedura di provisioning della template VM **Windows Server 2025** che alimenta i linked
|
||||||
|
clone usati per ogni job CI. Il template viene creato **una volta**; lo snapshot
|
||||||
|
`BaseClean` è la base immutabile per tutte le build successive.
|
||||||
|
|
||||||
|
**Prerequisito**: aver eseguito [HOST-SETUP.md](HOST-SETUP.md) (`F:\CI\` esiste,
|
||||||
|
credenziali archiviate, ISO Windows Server presente).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architettura: due 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) |
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specifiche VM
|
||||||
|
|
||||||
|
| Parametro | Valore |
|
||||||
|
| ----------------------- | --------------------------------------------------------------- |
|
||||||
|
| OS | Windows Server 2025 Standard/Datacenter (Core o Desktop OK) |
|
||||||
|
| vCPU | 4 |
|
||||||
|
| 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` |
|
||||||
|
| ISO | Windows Server 2025 (da `F:\CI\ISO\`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase A — Crea la VM e installa Windows
|
||||||
|
|
||||||
|
### A.1 Crea VM in VMware Workstation
|
||||||
|
- File → New Virtual Machine → Custom
|
||||||
|
- Hardware: 4 vCPU, 6144 MB, 80 GB thin
|
||||||
|
- Network: **VMnet8 (NAT)**
|
||||||
|
- VMX: `F:\CI\Templates\WinBuild\WinBuild.vmx`
|
||||||
|
- CD/DVD: monta ISO Windows Server 2025
|
||||||
|
|
||||||
|
### A.2 Installa Windows Server 2025
|
||||||
|
- Edition: Standard o Datacenter (Server Core o con Desktop)
|
||||||
|
- Activation: vedi A.4 (deve essere fatto prima dello snapshot)
|
||||||
|
|
||||||
|
### A.3 Abilita WinRM dentro la VM
|
||||||
|
Dentro la VM, come Administrator:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
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
|
||||||
|
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
### A.4 Attiva Windows Server 2025
|
||||||
|
**IMPORTANTE**: deve essere fatto **prima** dello snapshot. I linked clone ereditano
|
||||||
|
l'attivazione.
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
slmgr /dli
|
||||||
|
```
|
||||||
|
|
||||||
|
Se non attivato, scegli il metodo:
|
||||||
|
- **KMS** (server in rete): `slmgr /skms <IP_KMS>` → `slmgr /ato`
|
||||||
|
- **MAK/Retail key**: `slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX` → `slmgr /ato`
|
||||||
|
- **KMS pubblico** (lab): vedi https://github.com/robin113x/Windows-Server-Activation/
|
||||||
|
|
||||||
|
Verifica: `slmgr /xpr` — deve mostrare attivazione permanente o scadenza lontana.
|
||||||
|
|
||||||
|
### A.5 Annota IP DHCP
|
||||||
|
Dentro la VM:
|
||||||
|
```cmd
|
||||||
|
ipconfig
|
||||||
|
```
|
||||||
|
Annota l'IP `192.168.79.x` assegnato dal DHCP di VMnet8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase B — Provisioning automatico dall'host
|
||||||
|
|
||||||
|
Dall'host (PowerShell elevato):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd N:\Code\Workspace\Local-CI-CD-System\template
|
||||||
|
# Provisioning completo + registrazione credenziali automatica:
|
||||||
|
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.128 -StoreCredential
|
||||||
|
|
||||||
|
# Se WU già applicato in precedenza:
|
||||||
|
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.128 -SkipWindowsUpdate -StoreCredential
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cosa fa `Prepare-WinBuild2025.ps1` — passo per passo
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
9. **Gestisce reboot 3010**: se WU richiede reboot, riavvia VM e suggerisce re-run con `-SkipWindowsUpdate`
|
||||||
|
10. **Post-setup remote validation** (9 check via singola `Invoke-Command`):
|
||||||
|
- WinRM Running
|
||||||
|
- User `$BuildUsername` exists + admin
|
||||||
|
- UAC `EnableLUA=0`
|
||||||
|
- Firewall tutti profili Off
|
||||||
|
- .NET SDK installato
|
||||||
|
- Python `C:\Python\python.exe` presente
|
||||||
|
- MSBuild path presente
|
||||||
|
- `C:\CI\{build,output,scripts}` presenti
|
||||||
|
11. **Restore host WinRM** nel `finally` (sempre eseguito, anche su failure)
|
||||||
|
|
||||||
|
### Parametri principali
|
||||||
|
|
||||||
|
| Parametro | Default | Note |
|
||||||
|
| --------------------- | -------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| `-VMIPAddress` | (mandatory) | IP DHCP della VM su VMnet8 |
|
||||||
|
| `-AdminUsername` | Administrator | Account admin built-in nella VM |
|
||||||
|
| `-AdminPassword` | (prompt) | Se vuoto, prompt SecureString |
|
||||||
|
| `-BuildPassword` | (prompt) | Password per `ci_build` — mai hard-codare |
|
||||||
|
| `-BuildUsername` | ci_build | Account CI dentro la VM |
|
||||||
|
| `-DotNetSdkVersion` | 10.0 | Channel .NET SDK |
|
||||||
|
| `-SkipWindowsUpdate` | (off) | Pass-through a Setup-WinBuild2025.ps1 |
|
||||||
|
| `-StoreCredential` | (off) | Registra `BuildUsername`/`BuildPassword` in Windows Credential Manager |
|
||||||
|
| `-CredentialTarget` | BuildVMGuest | Target Credential Manager (deve corrispondere a quanto usato da Invoke-CIJob) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
```
|
||||||
|
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 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 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)
|
||||||
|
Step 9 VS Build Tools 2026 via Scheduled Task SYSTEM
|
||||||
|
Step 10 Toolchain cross-check (dotnet, python, msbuild)
|
||||||
|
Cleanup cleanmgr /sagerun:1 + DISM /StartComponentCleanup /ResetBase
|
||||||
|
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.
|
||||||
|
- **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.
|
||||||
|
- **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` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase D — Snapshot
|
||||||
|
|
||||||
|
**Solo se `Prepare-WinBuild2025.ps1` exit 0** (tutti gli `Assert-Step` passati):
|
||||||
|
|
||||||
|
1. **Spegni la VM**: Start → Shut down (graceful, non hard-stop)
|
||||||
|
2. **Take snapshot** in VMware Workstation:
|
||||||
|
- VM → Snapshot → Take Snapshot
|
||||||
|
- **Nome esatto: `BaseClean`** (case-sensitive, usato da `New-BuildVM.ps1`)
|
||||||
|
3. **Lascia la VM spenta** — non modificarla mai più dopo lo snapshot
|
||||||
|
4. **Verifica `runner/config.yaml`**: `GITEA_CI_TEMPLATE_PATH=F:\CI\Templates\WinBuild\CI-WinBuild.vmx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase E — Verifica finale
|
||||||
|
|
||||||
|
Dall'host:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Test creazione clone manuale
|
||||||
|
cd N:\Code\Workspace\Local-CI-CD-System\scripts
|
||||||
|
.\New-BuildVM.ps1 -JobId 'smoke-test'
|
||||||
|
|
||||||
|
# Verifica WinRM raggiungibile sul clone
|
||||||
|
.\Wait-VMReady.ps1 -JobId 'smoke-test' -TimeoutSec 120
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
.\Remove-BuildVM.ps1 -JobId 'smoke-test'
|
||||||
|
```
|
||||||
|
|
||||||
|
Se i 3 step exit 0 → template pronto per produzione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintenance — refresh semestrale
|
||||||
|
|
||||||
|
Il template va rinfrescato ogni ~180 giorni (KMS lease) o quando serve aggiornare la
|
||||||
|
toolchain (.NET, VS Build Tools, Python).
|
||||||
|
|
||||||
|
**Procedura**:
|
||||||
|
1. Power on della template VM (NIC: VMnet8 NAT)
|
||||||
|
2. Riattiva: `slmgr /ato`
|
||||||
|
3. Re-run `Prepare-WinBuild2025.ps1` per applicare update toolchain (passare flag opportuni)
|
||||||
|
4. Shut down + nuovo snapshot **versionato**: `BaseClean_<yyyyMMdd>` (TODO §2.5)
|
||||||
|
5. Aggiorna `GITEA_CI_SNAPSHOT_NAME` env var in `runner/config.yaml`
|
||||||
|
6. Tieni i 2 snapshot più recenti, cancella vecchi dopo 1 settimana di uso pulito
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Sintomo | Diagnosi / Fix |
|
||||||
|
| ------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `Cannot reach $VMIPAddress via WinRM` | VM non avviata, WinRM non abilitato, IP errato — verifica con ipconfig |
|
||||||
|
| `Setup-WinBuild2025.ps1 exited with code 3010`| Reboot richiesto post-WU. Re-run con `-SkipWindowsUpdate` |
|
||||||
|
| `[Defender] Validation failed: GPO key` | Defender riattivato da Windows Update — disabilita via gpedit + reboot |
|
||||||
|
| `VS Build Tools installation timed out` | Network slow o cache corrotta. Re-run con cache pulita (`C:\ProgramData\Microsoft\VisualStudio\Packages`) |
|
||||||
|
| `Python version match` fail | `$PythonVersion` ≠ versione installata. Aggiorna param o reinstalla |
|
||||||
|
| Post-setup `MSBuild not found` | VS install fallita silenziosa. Check `C:\CI\vs_log.txt` |
|
||||||
|
| Pre-snapshot Final check fail | Stato VM rotto — non prendere snapshot. Re-run o ricomincia da zero |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog correlato (TODO.md)
|
||||||
|
|
||||||
|
- **§1.1 [P0]** Migrare WinRM da HTTP/Basic a HTTPS/5986 con cert self-signed nel template
|
||||||
|
- **§1.3 [P0]** Pinning hash SHA256 degli installer (Python, dotnet-install.ps1, vs_buildtools.exe)
|
||||||
|
- **§1.6 [P2]** Documentare threat model per Defender/Firewall/UAC tutti off
|
||||||
|
- **§2.5 [P1]** Snapshot versionato `BaseClean_<yyyyMMdd>` (rollback in caso di refresh rotto)
|
||||||
|
- **§2.6 [P2]** Backup automatico VMDK template pre-snapshot
|
||||||
|
- **In-VM Git Clone** — installare Git for Windows + 7-Zip nel template per ottimizzare flusso
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: nsis-plugin-nsinnounp-${{ github.ref_name }}
|
name: nsis-plugin-nsinnounp-${{ github.ref_name }}
|
||||||
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
|
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# PSScriptAnalyzer lint — runs on every push/PR that touches PowerShell files.
|
||||||
|
# Requires PSScriptAnalyzer installed on the runner host:
|
||||||
|
# Install-Module PSScriptAnalyzer -Scope AllUsers -Force
|
||||||
|
#
|
||||||
|
# Failures block the PR. Fix warnings with:
|
||||||
|
# Invoke-ScriptAnalyzer -Path scripts\ -Recurse -Fix
|
||||||
|
|
||||||
|
name: Lint (PSScriptAnalyzer)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- '**.ps1'
|
||||||
|
- '**.psm1'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '**.ps1'
|
||||||
|
- '**.psm1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: windows-build
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run PSScriptAnalyzer
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) {
|
||||||
|
Write-Host "Installing PSScriptAnalyzer..."
|
||||||
|
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = @('scripts', 'template', 'runner', 'Setup-Host.ps1')
|
||||||
|
$results = $paths | ForEach-Object {
|
||||||
|
if (Test-Path $_) {
|
||||||
|
Invoke-ScriptAnalyzer -Path $_ -Recurse -Severity Error,Warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($results) {
|
||||||
|
$results | Format-Table -AutoSize
|
||||||
|
Write-Error "PSScriptAnalyzer found $($results.Count) issue(s). Fix before merging."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "PSScriptAnalyzer: no issues found."
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Downloads, registers, and installs act_runner as a Windows service.
|
Downloads, registers, and installs act_runner as a Windows service.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
DEPRECATED — use Setup-Host.ps1 instead.
|
||||||
|
Setup-Host.ps1 handles the full host bootstrap including act_runner service
|
||||||
|
installation in a single idempotent script. Install-Runner.ps1 is kept for
|
||||||
|
reference but may drift from Setup-Host.ps1 over time.
|
||||||
|
|
||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
1. Downloads the act_runner binary from the specified URL (or Gitea releases).
|
1. Downloads the act_runner binary from the specified URL (or Gitea releases).
|
||||||
2. Registers the runner with the Gitea instance using a registration token.
|
2. Registers the runner with the Gitea instance using a registration token.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Destroys ephemeral build VMs that were not cleaned up (e.g. after host power loss).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Scans F:\CI\BuildVMs\ for clone directories older than -MaxAgeHours.
|
||||||
|
For each stale directory, stops the VM (hard), deletes via vmrun deleteVM,
|
||||||
|
then removes the directory.
|
||||||
|
|
||||||
|
Run as a daily scheduled task or on host startup to prevent disk accumulation.
|
||||||
|
Safe to run while CI jobs are active — only touches VMs older than MaxAgeHours.
|
||||||
|
|
||||||
|
.PARAMETER CloneBaseDir
|
||||||
|
Directory containing ephemeral VM clones. Default: F:\CI\BuildVMs
|
||||||
|
|
||||||
|
.PARAMETER MaxAgeHours
|
||||||
|
Age threshold in hours. Directories with LastWriteTime older than this are
|
||||||
|
treated as orphaned. Must exceed the longest expected build duration.
|
||||||
|
Default: 4 (runner.timeout is 2h — 4h gives double margin)
|
||||||
|
|
||||||
|
.PARAMETER VmrunPath
|
||||||
|
Path to vmrun.exe.
|
||||||
|
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||||
|
|
||||||
|
.PARAMETER WhatIf
|
||||||
|
List orphaned VMs without destroying them.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Dry run
|
||||||
|
.\Cleanup-OrphanedBuildVMs.ps1 -WhatIf
|
||||||
|
|
||||||
|
# Live cleanup (run elevated)
|
||||||
|
.\Cleanup-OrphanedBuildVMs.ps1
|
||||||
|
#>
|
||||||
|
[CmdletBinding(SupportsShouldProcess)]
|
||||||
|
param(
|
||||||
|
[string] $CloneBaseDir = 'F:\CI\BuildVMs',
|
||||||
|
|
||||||
|
[ValidateRange(1, 168)]
|
||||||
|
[int] $MaxAgeHours = 4,
|
||||||
|
|
||||||
|
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Continue' # Don't abort on individual VM errors
|
||||||
|
|
||||||
|
if (-not (Test-Path $CloneBaseDir)) {
|
||||||
|
Write-Host "[Cleanup] Clone base dir not found: $CloneBaseDir — nothing to do."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
|
||||||
|
Write-Warning "[Cleanup] vmrun.exe not found at: $VmrunPath"
|
||||||
|
Write-Warning " Skipping vmrun stop/delete — will attempt directory removal only."
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = (Get-Date).AddHours(-$MaxAgeHours)
|
||||||
|
$orphans = Get-ChildItem -Path $CloneBaseDir -Directory -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.LastWriteTime -lt $cutoff }
|
||||||
|
|
||||||
|
if (-not $orphans) {
|
||||||
|
Write-Host "[Cleanup] No orphaned VMs found (threshold: $MaxAgeHours h)."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[Cleanup] Found $($orphans.Count) orphaned VM director$(if ($orphans.Count -eq 1) {'y'} else {'ies'}) older than $MaxAgeHours h."
|
||||||
|
|
||||||
|
foreach ($dir in $orphans) {
|
||||||
|
$vmx = Get-ChildItem -Path $dir.FullName -Filter '*.vmx' -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if ($PSCmdlet.ShouldProcess($dir.FullName, "Destroy orphaned VM")) {
|
||||||
|
Write-Host "[Cleanup] Processing: $($dir.FullName)"
|
||||||
|
|
||||||
|
if ($vmx -and (Test-Path $VmrunPath -PathType Leaf)) {
|
||||||
|
# Hard stop — don't wait for graceful shutdown on an orphan
|
||||||
|
& $VmrunPath -T ws stop $vmx.FullName hard 2>&1 | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$deleteOut = & $VmrunPath -T ws deleteVM $vmx.FullName 2>&1
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Warning "[Cleanup] vmrun deleteVM failed (exit $LASTEXITCODE): $deleteOut"
|
||||||
|
Write-Warning " Falling back to directory removal."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseif (-not $vmx) {
|
||||||
|
Write-Warning "[Cleanup] No .vmx found in $($dir.FullName) — removing directory only."
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
if (Test-Path $dir.FullName) {
|
||||||
|
Write-Warning "[Cleanup] Could not fully remove: $($dir.FullName) — manual cleanup needed."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[Cleanup] Removed: $($dir.FullName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[Cleanup] WhatIf: would destroy $($dir.FullName) (age: $([int]((Get-Date) - $dir.LastWriteTime).TotalHours) h)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[Cleanup] Done."
|
||||||
@@ -93,7 +93,7 @@ try {
|
|||||||
# ── Copy logs if requested ────────────────────────────────────────────
|
# ── Copy logs if requested ────────────────────────────────────────────
|
||||||
if ($IncludeLogs) {
|
if ($IncludeLogs) {
|
||||||
$logFiles = Invoke-Command -Session $session -ScriptBlock {
|
$logFiles = Invoke-Command -Session $session -ScriptBlock {
|
||||||
Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -ErrorAction SilentlyContinue |
|
Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -Recurse -ErrorAction SilentlyContinue |
|
||||||
Select-Object -ExpandProperty FullName
|
Select-Object -ExpandProperty FullName
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,10 +109,11 @@ try {
|
|||||||
throw "Artifact was not found at expected host path after copy: $hostDestPath"
|
throw "Artifact was not found at expected host path after copy: $hostDestPath"
|
||||||
}
|
}
|
||||||
|
|
||||||
$sizeKB = [math]::Round((Get-Item $hostDestPath).Length / 1KB, 1)
|
$fileItem = Get-Item $hostDestPath
|
||||||
if ($sizeKB -eq 0) {
|
if ($fileItem.Length -eq 0) {
|
||||||
throw "Artifact file exists but is empty: $hostDestPath"
|
throw "Artifact file exists but is empty: $hostDestPath"
|
||||||
}
|
}
|
||||||
|
$sizeKB = [math]::Round($fileItem.Length / 1KB, 1)
|
||||||
|
|
||||||
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
|
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
|
||||||
return $hostDestPath
|
return $hostDestPath
|
||||||
|
|||||||
@@ -190,8 +190,9 @@ $exitCode = 0
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
# ── Phase 1: Clone repo on HOST ───────────────────────────────────────
|
# ── Phase 1: Clone repo on HOST ───────────────────────────────────────
|
||||||
# The build VM has no internet/LAN access (Host-Only network).
|
# Build VM is on VMnet8 NAT (internet OK for builds), but source is
|
||||||
# Source is cloned here on the host, then copied into the VM via WinRM.
|
# still cloned here and copied via WinRM zip to avoid injecting a PAT
|
||||||
|
# into the VM environment.
|
||||||
Write-Host "`n[Phase 1/6] Cloning repository on host..."
|
Write-Host "`n[Phase 1/6] Cloning repository on host..."
|
||||||
if (Test-Path $hostCloneDir) { Remove-Item $hostCloneDir -Recurse -Force }
|
if (Test-Path $hostCloneDir) { Remove-Item $hostCloneDir -Recurse -Force }
|
||||||
$gitArgs = @('clone', '--depth', '1', '--branch', $Branch)
|
$gitArgs = @('clone', '--depth', '1', '--branch', $Branch)
|
||||||
@@ -208,6 +209,14 @@ try {
|
|||||||
$ErrorActionPreference = 'Continue'
|
$ErrorActionPreference = 'Continue'
|
||||||
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
|
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
|
||||||
$gitExit = $LASTEXITCODE
|
$gitExit = $LASTEXITCODE
|
||||||
|
if ($gitExit -ne 0) {
|
||||||
|
# Commit not in shallow history (e.g. manual run on older SHA).
|
||||||
|
# Fetch full history and retry.
|
||||||
|
Write-Host "[Invoke-CIJob] Shallow checkout failed — fetching full history..."
|
||||||
|
& git -C $hostCloneDir fetch --unshallow 2>&1 | Out-Null
|
||||||
|
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
|
||||||
|
$gitExit = $LASTEXITCODE
|
||||||
|
}
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
if ($gitExit -ne 0) {
|
if ($gitExit -ne 0) {
|
||||||
throw "git checkout $Commit failed (exit $gitExit):`n$($gitOutput -join "`n")"
|
throw "git checkout $Commit failed (exit $gitExit):`n$($gitOutput -join "`n")"
|
||||||
|
|||||||
@@ -94,15 +94,13 @@ try {
|
|||||||
# ── Ensure working directories exist on guest ────────────────────────
|
# ── Ensure working directories exist on guest ────────────────────────
|
||||||
Invoke-Command -Session $session -ScriptBlock {
|
Invoke-Command -Session $session -ScriptBlock {
|
||||||
param($workDir, $outputDir)
|
param($workDir, $outputDir)
|
||||||
foreach ($dir in @($workDir, (Split-Path $outputDir -Parent))) {
|
# Ensure artifact output parent exists
|
||||||
if (-not (Test-Path $dir)) {
|
$outParent = Split-Path $outputDir -Parent
|
||||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
if (-not (Test-Path $outParent)) {
|
||||||
}
|
New-Item -ItemType Directory -Path $outParent -Force | Out-Null
|
||||||
}
|
|
||||||
# Clean previous build if present
|
|
||||||
if (Test-Path $workDir) {
|
|
||||||
Remove-Item $workDir -Recurse -Force
|
|
||||||
}
|
}
|
||||||
|
# Clean and recreate build dir (ephemeral VM, but guard against partial prior run)
|
||||||
|
if (Test-Path $workDir) { Remove-Item $workDir -Recurse -Force }
|
||||||
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
|
||||||
} -ArgumentList $GuestWorkDir, $GuestArtifactZip
|
} -ArgumentList $GuestWorkDir, $GuestArtifactZip
|
||||||
|
|
||||||
|
|||||||
@@ -72,16 +72,37 @@ $stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1
|
|||||||
if ($stateOutput -match 'running') {
|
if ($stateOutput -match 'running') {
|
||||||
Write-Host "[Remove-BuildVM] Graceful stop timed out — forcing hard stop..."
|
Write-Host "[Remove-BuildVM] Graceful stop timed out — forcing hard stop..."
|
||||||
& $VmrunPath -T ws stop $VMPath hard 2>&1 | Out-Null
|
& $VmrunPath -T ws stop $VMPath hard 2>&1 | Out-Null
|
||||||
Start-Sleep -Seconds 3
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Step 4: Delete VM via vmrun (removes VMX + delta VMDK) ───────────────────
|
# Poll vmrun list until VMX is gone — vmware-vmx.exe may hold file locks briefly
|
||||||
|
# after stop. Waiting for the entry to disappear ensures deleteVM won't race the
|
||||||
|
# process exit.
|
||||||
|
$vmxNorm = $VMPath.Trim().ToLower()
|
||||||
|
$releaseDeadline = (Get-Date).AddSeconds(20)
|
||||||
|
while ((Get-Date) -lt $releaseDeadline) {
|
||||||
|
$listedVMs = & $VmrunPath -T ws list 2>&1 |
|
||||||
|
Where-Object { $_ -notmatch '^Total running' } |
|
||||||
|
ForEach-Object { "$_".Trim().ToLower() }
|
||||||
|
if ($listedVMs -notcontains $vmxNorm) { break }
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Step 4: Delete VM via vmrun — retry up to 3× with backoff ────────────────
|
||||||
Write-Host "[Remove-BuildVM] Deleting VM..."
|
Write-Host "[Remove-BuildVM] Deleting VM..."
|
||||||
|
$deleteOutput = ''
|
||||||
|
$deleteExit = -1
|
||||||
|
foreach ($delay in @(0, 3, 6)) {
|
||||||
|
if ($delay -gt 0) {
|
||||||
|
Write-Host "[Remove-BuildVM] Retrying deleteVM in ${delay}s..."
|
||||||
|
Start-Sleep -Seconds $delay
|
||||||
|
}
|
||||||
$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1
|
$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1
|
||||||
$deleteExit = $LASTEXITCODE
|
$deleteExit = $LASTEXITCODE
|
||||||
|
if ($deleteExit -eq 0) { break }
|
||||||
|
}
|
||||||
|
|
||||||
if ($deleteExit -ne 0) {
|
if ($deleteExit -ne 0) {
|
||||||
Write-Warning "[Remove-BuildVM] vmrun deleteVM returned exit code $deleteExit : $deleteOutput"
|
Write-Warning "[Remove-BuildVM] vmrun deleteVM failed after retries (exit $deleteExit): $deleteOutput"
|
||||||
Write-Warning "[Remove-BuildVM] Will attempt manual directory removal."
|
Write-Warning "[Remove-BuildVM] Will attempt manual directory removal."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs a full end-to-end CI test build of nsis-plugin-nsinnounp.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Invokes Invoke-CIJob.ps1 with the nsinnounp repo parameters and
|
||||||
|
verifies the artifact was produced. Useful for smoke-testing the
|
||||||
|
CI pipeline after changes to scripts or template VM.
|
||||||
|
|
||||||
|
Exits 0 on success, 1 on failure.
|
||||||
|
|
||||||
|
.PARAMETER JobId
|
||||||
|
Job identifier used for artifact and log directory names.
|
||||||
|
Default: e2e-test-<yyyyMMdd_HHmmss>
|
||||||
|
|
||||||
|
.PARAMETER Branch
|
||||||
|
Branch to build. Default: main
|
||||||
|
|
||||||
|
.PARAMETER Commit
|
||||||
|
Optional specific commit SHA. Default: empty (HEAD of branch).
|
||||||
|
|
||||||
|
.PARAMETER TemplatePath
|
||||||
|
Path to template VMX. Default: env:GITEA_CI_TEMPLATE_PATH or
|
||||||
|
F:\CI\Templates\WinBuild\CI-WinBuild.vmx
|
||||||
|
|
||||||
|
.PARAMETER VerifyArtifact
|
||||||
|
If set, unzips artifacts.zip and verifies expected DLL/EXE names
|
||||||
|
are present. Requires 7-Zip or Expand-Archive.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# Quick smoke test (HEAD of main):
|
||||||
|
.\Test-NsinnounpBuild.ps1
|
||||||
|
|
||||||
|
# Test a specific commit with artifact verification:
|
||||||
|
.\Test-NsinnounpBuild.ps1 -Commit abc1234 -VerifyArtifact
|
||||||
|
|
||||||
|
# Custom job ID to avoid colliding with a running e2e series:
|
||||||
|
.\Test-NsinnounpBuild.ps1 -JobId 'e2e-010'
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string] $JobId = "e2e-test-$(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||||
|
[string] $Branch = 'main',
|
||||||
|
[string] $Commit = '',
|
||||||
|
[string] $TemplatePath = $(
|
||||||
|
if ($env:GITEA_CI_TEMPLATE_PATH) { $env:GITEA_CI_TEMPLATE_PATH }
|
||||||
|
else { 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' }
|
||||||
|
),
|
||||||
|
[switch] $VerifyArtifact = $true
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||||
|
$orchestrator = Join-Path $scriptDir 'Invoke-CIJob.ps1'
|
||||||
|
$artifactDir = "F:\CI\Artifacts\$JobId"
|
||||||
|
$artifactZip = Join-Path $artifactDir 'artifacts.zip'
|
||||||
|
|
||||||
|
Write-Host "============================================================"
|
||||||
|
Write-Host " nsinnounp e2e test"
|
||||||
|
Write-Host " JobId : $JobId"
|
||||||
|
Write-Host " Branch : $Branch"
|
||||||
|
if ($Commit) { Write-Host " Commit : $Commit" }
|
||||||
|
Write-Host " Template : $TemplatePath"
|
||||||
|
Write-Host " Artifacts: $artifactDir"
|
||||||
|
Write-Host "============================================================"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$params = @{
|
||||||
|
JobId = $JobId
|
||||||
|
RepoUrl = 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git'
|
||||||
|
Branch = $Branch
|
||||||
|
TemplatePath = $TemplatePath
|
||||||
|
Submodules = $true
|
||||||
|
BuildCommand = 'python build_plugin.py --final --dist-dir dist'
|
||||||
|
GuestArtifactSource = 'dist'
|
||||||
|
GuestCredentialTarget = 'BuildVMGuest'
|
||||||
|
}
|
||||||
|
if ($Commit -ne '') { $params['Commit'] = $Commit }
|
||||||
|
|
||||||
|
$startTime = Get-Date
|
||||||
|
$exitCode = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $orchestrator @params
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "[Test] Invoke-CIJob.ps1 threw: $_"
|
||||||
|
$exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = (Get-Date) - $startTime
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "============================================================"
|
||||||
|
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
Write-Host "[Test] FAIL — Invoke-CIJob.ps1 exited $exitCode after $($elapsed.ToString('mm\:ss'))" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify artifact zip exists and is non-empty
|
||||||
|
if (-not (Test-Path $artifactZip)) {
|
||||||
|
Write-Host "[Test] FAIL — artifact not found: $artifactZip" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$zipSize = [math]::Round((Get-Item $artifactZip).Length / 1KB)
|
||||||
|
Write-Host "[Test] Artifact OK: $artifactZip ($zipSize KB)"
|
||||||
|
|
||||||
|
# Optional: expand and verify expected files are present
|
||||||
|
if ($VerifyArtifact) {
|
||||||
|
$extractDir = Join-Path $artifactDir 'verify'
|
||||||
|
Write-Host "[Test] Expanding artifact for verification..."
|
||||||
|
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
|
||||||
|
Expand-Archive -Path $artifactZip -DestinationPath $extractDir -Force
|
||||||
|
|
||||||
|
$expectedPatterns = @(
|
||||||
|
'*nsInnoUnp*.dll',
|
||||||
|
'*nsInnoUnpCLI*.exe'
|
||||||
|
)
|
||||||
|
$allFound = $true
|
||||||
|
foreach ($pattern in $expectedPatterns) {
|
||||||
|
$found = Get-ChildItem -Path $extractDir -Filter $pattern -Recurse -ErrorAction SilentlyContinue
|
||||||
|
if ($found) {
|
||||||
|
Write-Host "[Test] OK: $($found.Count) file(s) matching '$pattern'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "[Test] MISSING: no files matching '$pattern'" -ForegroundColor Red
|
||||||
|
$allFound = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if (-not $allFound) {
|
||||||
|
Write-Host "[Test] FAIL — artifact missing expected files." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[Test] PASS — $JobId completed in $($elapsed.ToString('mm\:ss'))" -ForegroundColor Green
|
||||||
|
Write-Host " Artifacts: $artifactDir"
|
||||||
|
Write-Host "============================================================"
|
||||||
|
exit 0
|
||||||
@@ -75,15 +75,10 @@ while ((Get-Date) -lt $deadline) {
|
|||||||
$attempt++
|
$attempt++
|
||||||
|
|
||||||
# ── Phase 1: VM must be running ─────────────────────────────────────
|
# ── Phase 1: VM must be running ─────────────────────────────────────
|
||||||
# vmrun has no getState; getGuestIPAddress fails with "not powered on"
|
# vmrun has no getState; getGuestIPAddress exits non-zero when the VM
|
||||||
# when the VM is off, and succeeds (exit 0) when running.
|
# is not powered on. Native commands don't throw — check exit code only.
|
||||||
try {
|
|
||||||
& $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null
|
& $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null
|
||||||
$isRunning = ($LASTEXITCODE -eq 0)
|
$isRunning = ($LASTEXITCODE -eq 0)
|
||||||
}
|
|
||||||
catch {
|
|
||||||
$isRunning = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $isRunning) {
|
if (-not $isRunning) {
|
||||||
if ($phase -ne 'vmrun-state') {
|
if ($phase -ne 'vmrun-state') {
|
||||||
@@ -116,7 +111,6 @@ while ((Get-Date) -lt $deadline) {
|
|||||||
try {
|
try {
|
||||||
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
|
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
|
||||||
# Success — VM is ready
|
# Success — VM is ready
|
||||||
$elapsed = ($deadline - (Get-Date)).Negate().TotalSeconds + $TimeoutSeconds
|
|
||||||
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
|
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,938 @@
|
|||||||
|
#Requires -RunAsAdministrator
|
||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Deploys an unattended Windows Server 2025 VM on VMware Workstation
|
||||||
|
and installs VMware Tools automatically.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Host-side orchestrator. Produces a ready-to-use template VM:
|
||||||
|
Step 1 — Validate prerequisites (vmrun, vmware-vdiskmanager, ISOs)
|
||||||
|
Step 2 — Render autounattend.xml from template + post-install.ps1
|
||||||
|
(placeholders substituted with parameter values)
|
||||||
|
Step 3 — Build autounattend ISO (autounattend.xml + post-install.ps1)
|
||||||
|
via IMAPI2FS COM (built-in Windows, zero deps)
|
||||||
|
Step 4 — Create the VMDK (80 GB single-file thin) via vmware-vdiskmanager
|
||||||
|
Step 5 — Generate the .vmx (UEFI no SecureBoot, NVMe disk, e1000e NIC,
|
||||||
|
three CD-ROMs: Win install + autounattend + VMware Tools)
|
||||||
|
Step 6 — Power on the VM (vmrun start nogui)
|
||||||
|
Step 7 — Wait for the install_complete.flag file inside the guest
|
||||||
|
(created by post-install.ps1 once Tools install finishes)
|
||||||
|
Step 8 — Graceful soft-stop, swap NIC e1000e → vmxnet3 in the .vmx
|
||||||
|
Step 9 — Power on, wait for guest IP via vmrun (vmxnet3 verified)
|
||||||
|
Step 10 — Take snapshot named $SnapshotName
|
||||||
|
Final — Print summary (VM path, IP, snapshot name)
|
||||||
|
|
||||||
|
NIC strategy:
|
||||||
|
Boot install with e1000e (broad OOB Windows compatibility). VMware Tools
|
||||||
|
install brings the vmxnet3 driver. Post-Tools, the host swaps the .vmx
|
||||||
|
to vmxnet3 and reboots. This avoids driver injection in WinPE.
|
||||||
|
|
||||||
|
Disk layout (UEFI/GPT, 80 GB):
|
||||||
|
Part 1: EFI 100 MB FAT32 S:
|
||||||
|
Part 2: MSR 16 MB
|
||||||
|
Part 3: Primary rest NTFS C: ← Windows installs here
|
||||||
|
No Recovery partition (per user choice).
|
||||||
|
|
||||||
|
Hardening done by post-install.ps1 inside the guest:
|
||||||
|
- UAC off (EnableLUA=0, LocalAccountTokenFilterPolicy=1)
|
||||||
|
- Defender real-time protection off
|
||||||
|
- DiagTrack/Telemetry off
|
||||||
|
- Windows Update services disabled
|
||||||
|
- RDP enabled (no NLA, lab only) + firewall rule
|
||||||
|
- WinRM enabled HTTP 5985 + HTTPS 5986 (self-signed cert), Basic auth,
|
||||||
|
AllowUnencrypted, TrustedHosts=*
|
||||||
|
- ICMPv4 inbound allowed
|
||||||
|
- VMware Tools installed (Complete) from Tools ISO
|
||||||
|
- install_complete.flag dropped in C:\Windows\Temp
|
||||||
|
|
||||||
|
.PARAMETER WinISO
|
||||||
|
Path to the Windows Server 2025 install ISO.
|
||||||
|
|
||||||
|
.PARAMETER ToolsISO
|
||||||
|
Path to the VMware Tools ISO (windows.iso).
|
||||||
|
|
||||||
|
.PARAMETER VMXPath
|
||||||
|
Full path of the .vmx file to create. The folder is created if missing.
|
||||||
|
|
||||||
|
.PARAMETER VMName
|
||||||
|
Display name in the Workstation library. Defaults to the .vmx file basename.
|
||||||
|
|
||||||
|
.PARAMETER ProductKey
|
||||||
|
Windows Server 2025 product key to embed in autounattend.
|
||||||
|
|
||||||
|
.PARAMETER ComputerName
|
||||||
|
Hostname. Max 15 chars, no spaces.
|
||||||
|
|
||||||
|
.PARAMETER AdminUser
|
||||||
|
.PARAMETER AdminPassword
|
||||||
|
Local Administrator account credentials.
|
||||||
|
|
||||||
|
.PARAMETER ImageIndex
|
||||||
|
Index of the install.wim edition to deploy. Default: 2 (Server 2025
|
||||||
|
Standard Desktop Experience on the VOL ISO). Verify with:
|
||||||
|
dism /Get-WimInfo /WimFile:<isomount>\sources\install.wim
|
||||||
|
|
||||||
|
.PARAMETER DiskSizeGB
|
||||||
|
Virtual disk size in GiB. Default: 80.
|
||||||
|
|
||||||
|
.PARAMETER MemoryMB
|
||||||
|
Guest RAM in MiB. Default: 6144 (6 GB).
|
||||||
|
|
||||||
|
.PARAMETER VCPUCount
|
||||||
|
Total vCPU. Default: 2.
|
||||||
|
|
||||||
|
.PARAMETER CoresPerSocket
|
||||||
|
Cores per socket. Default: 2 (=> 1 socket × 2 cores).
|
||||||
|
|
||||||
|
.PARAMETER LocaleOS
|
||||||
|
OS UI / system locale. Default: en-US.
|
||||||
|
|
||||||
|
.PARAMETER LocaleUser
|
||||||
|
User locale. Default: it-IT.
|
||||||
|
|
||||||
|
.PARAMETER InputKeyboard
|
||||||
|
Keyboard layout id. Default: 0410:00000410 (Italian).
|
||||||
|
|
||||||
|
.PARAMETER TimeZone
|
||||||
|
Windows time zone id. Default: W. Europe Standard Time.
|
||||||
|
|
||||||
|
.PARAMETER SnapshotName
|
||||||
|
Snapshot name taken at the end. Default: PostInstall.
|
||||||
|
|
||||||
|
.PARAMETER VMwareWorkstationDir
|
||||||
|
Override Workstation install dir if not at the default location.
|
||||||
|
|
||||||
|
.PARAMETER GuestPollSeconds
|
||||||
|
Seconds between polls when waiting for install_complete.flag. Default: 30.
|
||||||
|
|
||||||
|
.PARAMETER GuestPollMaxMinutes
|
||||||
|
Hard timeout waiting for install_complete.flag. Default: 90.
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
Run from elevated PowerShell on the host. The VM IP detection (Step 9)
|
||||||
|
requires VMware Tools to be running in the guest — guaranteed because
|
||||||
|
Step 7 waited for the install_complete.flag dropped after Tools install.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
# ── ISOs ──
|
||||||
|
[string] $WinISO = 'F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso',
|
||||||
|
[string] $ToolsISO = 'F:\CI\ISO\VMware-tools-windows.iso',
|
||||||
|
|
||||||
|
# Cache path for the rebuilt no-prompt Windows ISO. Reused across runs when
|
||||||
|
# newer than the source. Empty string => rebuild every run inside the VM dir.
|
||||||
|
[string] $WinISONoPromptPath = '',
|
||||||
|
|
||||||
|
# ── VM identity ──
|
||||||
|
[string] $VMXPath = 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx',
|
||||||
|
[string] $VMName = 'WinBuild2025',
|
||||||
|
|
||||||
|
# ── Windows install ──
|
||||||
|
[string] $ProductKey = 'TVRH6-WHNXV-R9WG3-9XRFY-MY832',
|
||||||
|
[string] $ComputerName = 'WINBUILD-2025',
|
||||||
|
[string] $AdminUser = 'Administrator',
|
||||||
|
[string] $AdminPassword = 'WinBuild!',
|
||||||
|
[int] $ImageIndex = 2,
|
||||||
|
|
||||||
|
# ── Hardware ──
|
||||||
|
[int] $DiskSizeGB = 80,
|
||||||
|
[int] $MemoryMB = 6144,
|
||||||
|
[int] $VCPUCount = 2,
|
||||||
|
[int] $CoresPerSocket = 2,
|
||||||
|
|
||||||
|
# ── Locale ──
|
||||||
|
[string] $LocaleOS = 'en-US',
|
||||||
|
[string] $LocaleUser = 'it-IT',
|
||||||
|
[string] $InputKeyboard = '0410:00000410',
|
||||||
|
[string] $TimeZone = 'W. Europe Standard Time',
|
||||||
|
|
||||||
|
# ── Finalization ──
|
||||||
|
[string] $SnapshotName = 'PostInstall',
|
||||||
|
|
||||||
|
# ── Tooling ──
|
||||||
|
[string] $VMwareWorkstationDir = 'C:\Program Files (x86)\VMware\VMware Workstation',
|
||||||
|
|
||||||
|
# ── Polling ──
|
||||||
|
[int] $GuestPollSeconds = 30,
|
||||||
|
[int] $GuestPollMaxMinutes = 90,
|
||||||
|
|
||||||
|
# When set, vmrun powers on the VM with the Workstation GUI visible.
|
||||||
|
# Default: headless (nogui) for unattended pipelines.
|
||||||
|
[switch] $ShowGui
|
||||||
|
)
|
||||||
|
|
||||||
|
$vmrunUiMode = if ($ShowGui) { 'gui' } else { 'nogui' }
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "`n=== $Message ===" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Step {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string] $StepName,
|
||||||
|
[Parameter(Mandatory)] [string] $Description,
|
||||||
|
[Parameter(Mandatory)] [scriptblock] $Test
|
||||||
|
)
|
||||||
|
$ok = $false
|
||||||
|
try { $ok = [bool](& $Test) }
|
||||||
|
catch { throw "[$StepName] Validation threw on '$Description': $_" }
|
||||||
|
if (-not $ok) { throw "[$StepName] Validation failed: $Description" }
|
||||||
|
Write-Host " [OK] $Description" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# IMAPI2FS-based ISO writer. No external deps. Inline C# bridges IStream → file.
|
||||||
|
$isoWriterCs = @"
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.InteropServices.ComTypes;
|
||||||
|
public class IsoStreamWriter2 {
|
||||||
|
[DllImport("shlwapi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)]
|
||||||
|
static extern int SHCreateStreamOnFileEx(string pszFile, uint grfMode, uint dwAttributes, bool fCreate, IntPtr pstmTemplate, out IStream ppstm);
|
||||||
|
|
||||||
|
public static IStream OpenFileAsStream(string path) {
|
||||||
|
IStream s;
|
||||||
|
// STGM_READ | STGM_SHARE_DENY_WRITE = 0x00000020
|
||||||
|
int hr = SHCreateStreamOnFileEx(path, 0x20, 0, false, IntPtr.Zero, out s);
|
||||||
|
if (hr != 0) throw new Exception("SHCreateStreamOnFileEx 0x" + hr.ToString("X"));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Save(object streamObj, string path) {
|
||||||
|
IStream istream = streamObj as IStream;
|
||||||
|
if (istream == null) {
|
||||||
|
// Fallback: QueryInterface for IStream IID via Marshal.
|
||||||
|
IntPtr unk = Marshal.GetIUnknownForObject(streamObj);
|
||||||
|
try {
|
||||||
|
Guid iid = new Guid("0000000C-0000-0000-C000-000000000046");
|
||||||
|
IntPtr pStream;
|
||||||
|
int hr = Marshal.QueryInterface(unk, ref iid, out pStream);
|
||||||
|
if (hr != 0) throw new InvalidCastException("QueryInterface(IStream) failed: 0x" + hr.ToString("X"));
|
||||||
|
try {
|
||||||
|
istream = (IStream)Marshal.GetObjectForIUnknown(pStream);
|
||||||
|
} finally {
|
||||||
|
Marshal.Release(pStream);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Marshal.Release(unk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
using (FileStream fs = File.Open(path, FileMode.Create, FileAccess.Write)) {
|
||||||
|
byte[] buf = new byte[2048 * 16];
|
||||||
|
IntPtr pcb = Marshal.AllocHGlobal(8);
|
||||||
|
try {
|
||||||
|
int n;
|
||||||
|
do {
|
||||||
|
istream.Read(buf, buf.Length, pcb);
|
||||||
|
n = Marshal.ReadInt32(pcb);
|
||||||
|
if (n > 0) fs.Write(buf, 0, n);
|
||||||
|
} while (n > 0);
|
||||||
|
} finally {
|
||||||
|
Marshal.FreeHGlobal(pcb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
if (-not ([System.Management.Automation.PSTypeName]'IsoStreamWriter2').Type) {
|
||||||
|
Add-Type -TypeDefinition $isoWriterCs -Language CSharp
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-IsoFromFolder {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string] $SourceFolder,
|
||||||
|
[Parameter(Mandatory)] [string] $IsoPath,
|
||||||
|
[Parameter(Mandatory)] [string] $VolumeLabel
|
||||||
|
)
|
||||||
|
if (Test-Path $IsoPath) { Remove-Item $IsoPath -Force }
|
||||||
|
$fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage
|
||||||
|
$result = $null
|
||||||
|
$stream = $null
|
||||||
|
try {
|
||||||
|
$fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF
|
||||||
|
$fsi.VolumeName = $VolumeLabel
|
||||||
|
$fsi.ChooseImageDefaultsForMediaType(13) # IMAPI_MEDIA_TYPE_DISK
|
||||||
|
$fsi.Root.AddTree($SourceFolder, $false)
|
||||||
|
$result = $fsi.CreateResultImage()
|
||||||
|
$stream = $result.ImageStream
|
||||||
|
[IsoStreamWriter2]::Save($stream, $IsoPath)
|
||||||
|
} finally {
|
||||||
|
# Release in reverse order. ImageStream holds file handles to source
|
||||||
|
# files added via AddTree — must be released before the source folder
|
||||||
|
# can be deleted.
|
||||||
|
if ($stream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($stream) | Out-Null }
|
||||||
|
if ($result) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($result) | Out-Null }
|
||||||
|
[System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($fsi) | Out-Null
|
||||||
|
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-WinIsoNoPrompt {
|
||||||
|
<#
|
||||||
|
Rebuilds a Windows install ISO replacing the EFI boot image with
|
||||||
|
efisys_noprompt.bin (extracted from the source ISO itself).
|
||||||
|
Result: UEFI boot proceeds straight into Windows Setup, no
|
||||||
|
"Press any key to boot from CD" prompt.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string] $SourceIsoPath,
|
||||||
|
[Parameter(Mandatory)] [string] $OutputIsoPath
|
||||||
|
)
|
||||||
|
if (Test-Path $OutputIsoPath) { Remove-Item $OutputIsoPath -Force }
|
||||||
|
|
||||||
|
$mount = Mount-DiskImage -ImagePath $SourceIsoPath -PassThru -StorageType ISO -Access ReadOnly
|
||||||
|
try {
|
||||||
|
$vol = $mount | Get-Volume
|
||||||
|
$letter = "$($vol.DriveLetter):\"
|
||||||
|
$bootImg = Join-Path $letter 'efi\microsoft\boot\efisys_noprompt.bin'
|
||||||
|
if (-not (Test-Path $bootImg)) {
|
||||||
|
throw "efisys_noprompt.bin not present in source ISO at $bootImg"
|
||||||
|
}
|
||||||
|
$label = $vol.FileSystemLabel
|
||||||
|
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'WINSERVER' }
|
||||||
|
if ($label.Length -gt 32) { $label = $label.Substring(0,32) }
|
||||||
|
|
||||||
|
$fsi = New-Object -ComObject IMAPI2FS.MsftFileSystemImage
|
||||||
|
try {
|
||||||
|
$fsi.FileSystemsToCreate = 7 # ISO9660 | Joliet | UDF
|
||||||
|
$fsi.VolumeName = $label
|
||||||
|
$fsi.ChooseImageDefaultsForMediaType(13)
|
||||||
|
# 5+ GB ISO needs explicit large-volume defaults if available.
|
||||||
|
try { $fsi.UDFRevision = 0x102 } catch {}
|
||||||
|
|
||||||
|
Write-Host " Adding ISO tree (this takes a few minutes for 5+ GB)..." -ForegroundColor Yellow
|
||||||
|
$fsi.Root.AddTree($letter, $false)
|
||||||
|
|
||||||
|
$bootStream = [IsoStreamWriter2]::OpenFileAsStream($bootImg)
|
||||||
|
$bootOpts = New-Object -ComObject IMAPI2FS.BootOptions
|
||||||
|
$bootOpts.AssignBootImage($bootStream)
|
||||||
|
$bootOpts.Emulation = 0 # IMAPI_BOOT_EMULATION_TYPE_NONE
|
||||||
|
$bootOpts.PlatformId = 0xEF # EFI
|
||||||
|
$bootOpts.Manufacturer = 'Microsoft'
|
||||||
|
|
||||||
|
# Single-element COM array for BootImageOptionsArray
|
||||||
|
$arr = New-Object 'object[]' 1
|
||||||
|
$arr[0] = $bootOpts
|
||||||
|
try { $fsi.BootImageOptionsArray = $arr }
|
||||||
|
catch { $fsi.BootImageOptions = $bootOpts } # fallback older IMAPI2FS
|
||||||
|
|
||||||
|
Write-Host " Building output ISO..." -ForegroundColor Yellow
|
||||||
|
$result = $fsi.CreateResultImage()
|
||||||
|
$stream = $result.ImageStream
|
||||||
|
[IsoStreamWriter2]::Save($stream, $OutputIsoPath)
|
||||||
|
} finally {
|
||||||
|
if ($stream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($stream) | Out-Null }
|
||||||
|
if ($result) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($result) | Out-Null }
|
||||||
|
if ($bootStream) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($bootStream) | Out-Null }
|
||||||
|
if ($bootOpts) { [System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($bootOpts) | Out-Null }
|
||||||
|
[System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($fsi) | Out-Null
|
||||||
|
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Dismount-DiskImage -ImagePath $SourceIsoPath -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Vmrun {
|
||||||
|
param([Parameter(Mandatory)] [string[]] $Arguments,
|
||||||
|
[switch] $IgnoreErrors)
|
||||||
|
$vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe'
|
||||||
|
$stdout = & $vmrun @Arguments 2>&1
|
||||||
|
$code = $LASTEXITCODE
|
||||||
|
if ($code -ne 0 -and -not $IgnoreErrors) {
|
||||||
|
throw "vmrun $($Arguments -join ' ') failed (exit $code): $stdout"
|
||||||
|
}
|
||||||
|
return $stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
function Set-VmxKey {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)] [string] $VmxPath,
|
||||||
|
[Parameter(Mandatory)] [string] $Key,
|
||||||
|
[Parameter(Mandatory)] [string] $Value
|
||||||
|
)
|
||||||
|
$lines = Get-Content -LiteralPath $VmxPath
|
||||||
|
$pattern = "^\s*$([regex]::Escape($Key))\s*="
|
||||||
|
$found = $false
|
||||||
|
$newLines = foreach ($line in $lines) {
|
||||||
|
if ($line -match $pattern) { $found = $true; "$Key = `"$Value`"" }
|
||||||
|
else { $line }
|
||||||
|
}
|
||||||
|
if (-not $found) { $newLines = @($newLines) + "$Key = `"$Value`"" }
|
||||||
|
Set-Content -LiteralPath $VmxPath -Value $newLines -Encoding ASCII
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 1: Pre-flight validation
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 1: pre-flight validation'
|
||||||
|
|
||||||
|
$vmrunPath = Join-Path $VMwareWorkstationDir 'vmrun.exe'
|
||||||
|
$vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe'
|
||||||
|
$scriptRoot = Split-Path -Parent $PSCommandPath
|
||||||
|
$xmlTemplate = Join-Path $scriptRoot 'autounattend.template.xml'
|
||||||
|
|
||||||
|
Assert-Step 'PreFlight' "vmrun.exe found at $vmrunPath" { Test-Path $vmrunPath }
|
||||||
|
Assert-Step 'PreFlight' "vmware-vdiskmanager.exe found at $vdiskMgr" { Test-Path $vdiskMgr }
|
||||||
|
Assert-Step 'PreFlight' "Windows ISO exists: $WinISO" { Test-Path $WinISO }
|
||||||
|
Assert-Step 'PreFlight' "VMware Tools ISO exists: $ToolsISO" { Test-Path $ToolsISO }
|
||||||
|
Assert-Step 'PreFlight' "autounattend template exists: $xmlTemplate" { Test-Path $xmlTemplate }
|
||||||
|
Assert-Step 'PreFlight' "ComputerName valid (1-15 chars, no spaces)" {
|
||||||
|
$ComputerName -match '^[A-Za-z0-9-]{1,15}$'
|
||||||
|
}
|
||||||
|
|
||||||
|
$vmDir = Split-Path -Parent $VMXPath
|
||||||
|
if (-not (Test-Path $vmDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $vmDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir }
|
||||||
|
|
||||||
|
if (Test-Path $VMXPath) {
|
||||||
|
throw "[PreFlight] $VMXPath already exists. Remove the existing VM folder before re-running."
|
||||||
|
}
|
||||||
|
|
||||||
|
$vmdkName = [IO.Path]::GetFileNameWithoutExtension($VMXPath) + '.vmdk'
|
||||||
|
$vmdkPath = Join-Path $vmDir $vmdkName
|
||||||
|
$autounattendIso = Join-Path $vmDir 'autounattend.iso'
|
||||||
|
$stagingDir = Join-Path $vmDir '_autounattend_staging'
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 2: render autounattend.xml + post-install.ps1
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 2: render autounattend.xml + post-install.ps1'
|
||||||
|
|
||||||
|
if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
|
||||||
|
|
||||||
|
# autounattend.xml
|
||||||
|
$xmlText = Get-Content -LiteralPath $xmlTemplate -Raw
|
||||||
|
$xmlSubs = @{
|
||||||
|
'{{LOCALE_OS}}' = $LocaleOS
|
||||||
|
'{{LOCALE_USER}}' = $LocaleUser
|
||||||
|
'{{KEYBOARD}}' = $InputKeyboard
|
||||||
|
'{{IMAGE_INDEX}}' = "$ImageIndex"
|
||||||
|
'{{PRODUCT_KEY}}' = $ProductKey
|
||||||
|
'{{COMPUTER_NAME}}' = $ComputerName
|
||||||
|
'{{TIMEZONE}}' = $TimeZone
|
||||||
|
'{{ADMIN_USER}}' = $AdminUser
|
||||||
|
'{{ADMIN_PWD}}' = $AdminPassword
|
||||||
|
}
|
||||||
|
foreach ($k in $xmlSubs.Keys) { $xmlText = $xmlText.Replace($k, $xmlSubs[$k]) }
|
||||||
|
$xmlOut = Join-Path $stagingDir 'autounattend.xml'
|
||||||
|
Set-Content -LiteralPath $xmlOut -Value $xmlText -Encoding UTF8
|
||||||
|
Assert-Step 'Render' 'autounattend.xml written' { Test-Path $xmlOut }
|
||||||
|
Assert-Step 'Render' 'no placeholders left in xml' { -not (Select-String -Path $xmlOut -Pattern '\{\{[^}]+\}\}' -Quiet) }
|
||||||
|
|
||||||
|
# post-install.ps1 — drops into autounattend ISO root, runs at FirstLogonCommands.
|
||||||
|
# Hardening + WinRM + Tools install + flag file + reboot. All paths are absolute.
|
||||||
|
$postInstall = @"
|
||||||
|
# Auto-generated by Deploy-WinBuild2025.ps1 — runs at first logon as Administrator.
|
||||||
|
`$ErrorActionPreference = 'Continue'
|
||||||
|
`$log = 'C:\Windows\Temp\post-install.log'
|
||||||
|
function L(`$m){ "`$(Get-Date -f s) `$m" | Tee-Object -FilePath `$log -Append }
|
||||||
|
|
||||||
|
L 'post-install start'
|
||||||
|
|
||||||
|
# ── KICK OFF VMware Tools install (async — wrapper detaches MSI child) ────
|
||||||
|
# Started first so the MSI runs in the background while hardening tweaks
|
||||||
|
# below execute. Settled state verified at the very end before reboot.
|
||||||
|
`$selfDir = Split-Path -Parent `$MyInvocation.MyCommand.Path
|
||||||
|
`$toolsExe = `$null
|
||||||
|
foreach (`$name in 'setup64.exe','setup.exe') {
|
||||||
|
`$cand = Join-Path `$selfDir "Tools\`$name"
|
||||||
|
if (Test-Path `$cand) { `$toolsExe = `$cand; break }
|
||||||
|
}
|
||||||
|
if (-not `$toolsExe) {
|
||||||
|
L 'Bundled Tools not found, scanning CD drives...'
|
||||||
|
foreach (`$drv in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=5')) {
|
||||||
|
foreach (`$name in 'setup64.exe','setup.exe') {
|
||||||
|
`$cand = Join-Path (`$drv.DeviceID + '\') `$name
|
||||||
|
if (Test-Path `$cand) { `$toolsExe = `$cand; break }
|
||||||
|
}
|
||||||
|
if (`$toolsExe) { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
L "Tools setup resolved to: `$toolsExe"
|
||||||
|
|
||||||
|
`$localRoot = 'C:\Windows\Temp\VMwareTools'
|
||||||
|
`$msiLog = 'C:\Windows\Temp\vmtools-msi.log'
|
||||||
|
`$exitCode = -1
|
||||||
|
if (`$toolsExe -and (Test-Path `$toolsExe)) {
|
||||||
|
# Copy payload to local writable disk — wrapper extract from CD fails at
|
||||||
|
# FirstLogon phase due to user profile %TEMP% ACL bootstrap timing.
|
||||||
|
`$srcRoot = Split-Path -Parent `$toolsExe
|
||||||
|
if (Test-Path `$localRoot) { Remove-Item `$localRoot -Recurse -Force }
|
||||||
|
L "Copying Tools payload `$srcRoot -> `$localRoot"
|
||||||
|
Copy-Item -Path `$srcRoot -Destination `$localRoot -Recurse -Force
|
||||||
|
|
||||||
|
# PS argument parser strips inner quotes from /v"..."; --% stop-parsing
|
||||||
|
# token passes the tail literally to the EXE.
|
||||||
|
L "Tools cmd: .\setup.exe /s /v`"/qn REBOOT=R /l*v `$msiLog`""
|
||||||
|
Push-Location `$localRoot
|
||||||
|
try {
|
||||||
|
& .\setup.exe --% /s /v"/qn REBOOT=R /l*v C:\Windows\Temp\vmtools-msi.log"
|
||||||
|
`$exitCode = `$LASTEXITCODE
|
||||||
|
} finally { Pop-Location }
|
||||||
|
L "VMware Tools wrapper exit code: `$exitCode (MSI continues async)"
|
||||||
|
} else {
|
||||||
|
L 'ERROR: VMware Tools setup not found (bundled or on any CD-ROM)'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── UAC off (admin script automation) ─────────────────────────────────────
|
||||||
|
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v EnableLUA /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
L 'UAC disabled'
|
||||||
|
|
||||||
|
# ── Defender disabled (lab only) — Setup-WinBuild2025 skips Defender step ─
|
||||||
|
# RTP off + tamper-protect GPO. With Defender fully off, exclusion paths
|
||||||
|
# become moot (no scanner to exclude from), so Setup-WinBuild2025 has been
|
||||||
|
# stripped of its Defender step.
|
||||||
|
try { Set-MpPreference -DisableRealtimeMonitoring `$true -ErrorAction Stop; L 'Defender RTP off' }
|
||||||
|
catch { L "Defender RTP off failed: `$(`$_.Exception.Message)" }
|
||||||
|
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows Defender' /v DisableAntiSpyware /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
|
||||||
|
# ── Telemetry / DiagTrack off ─────────────────────────────────────────────
|
||||||
|
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection' /v AllowTelemetry /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
sc.exe config DiagTrack start= disabled | Out-Null
|
||||||
|
sc.exe stop DiagTrack | Out-Null
|
||||||
|
L 'Telemetry disabled'
|
||||||
|
|
||||||
|
# ── Windows Update services disabled ──────────────────────────────────────
|
||||||
|
foreach (`$svc in 'wuauserv','UsoSvc','WaaSMedicSvc') {
|
||||||
|
sc.exe config `$svc start= disabled | Out-Null
|
||||||
|
sc.exe stop `$svc | Out-Null
|
||||||
|
}
|
||||||
|
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
L 'Windows Update disabled'
|
||||||
|
|
||||||
|
# ── Firewall: profiles stay default; allow RDP + ICMP + WinRM HTTPS ──────
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server' /v fDenyTSConnections /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' /v UserAuthentication /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue
|
||||||
|
New-NetFirewallRule -DisplayName 'ICMPv4-In' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
L 'RDP + ICMP allowed'
|
||||||
|
|
||||||
|
# ── WinRM HTTP + HTTPS, Basic+Unencrypted, TrustedHosts=* (lab only) ─────
|
||||||
|
Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null
|
||||||
|
winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null
|
||||||
|
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
|
||||||
|
winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null
|
||||||
|
winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null
|
||||||
|
try {
|
||||||
|
`$cert = New-SelfSignedCertificate -DnsName `$env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My
|
||||||
|
New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint `$cert.Thumbprint -Force | Out-Null
|
||||||
|
New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any | Out-Null
|
||||||
|
L 'WinRM HTTPS listener configured'
|
||||||
|
} catch { L "WinRM HTTPS failed: `$(`$_.Exception.Message)" }
|
||||||
|
|
||||||
|
# ── Server Manager / WAC popup / first-logon UX ──────────────────────────
|
||||||
|
# Server Manager auto-start off — machine-wide + current Administrator + Default
|
||||||
|
# profile hive (so future cloned/sysprep'd users inherit).
|
||||||
|
reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
reg add 'HKCU\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
reg load HKU\DefaultUser 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null
|
||||||
|
reg add 'HKU\DefaultUser\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
reg unload HKU\DefaultUser 2>&1 | Out-Null
|
||||||
|
# Windows Admin Center prompt at SM launch off
|
||||||
|
reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotPopWACConsoleAtSMLaunch /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
# DisableCAD — no Ctrl+Alt+Del at logon (CI VMs never need it)
|
||||||
|
reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
# OOBE privacy experience suppressed
|
||||||
|
reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
L 'Server Manager + WAC + CAD + OOBE prompts disabled'
|
||||||
|
|
||||||
|
# ── IE Enhanced Security Configuration off (Admin) ───────────────────────
|
||||||
|
# Long-known CI papercut: ESC blocks every URL in admin sessions.
|
||||||
|
reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
Stop-Process -Name iexplore -ErrorAction SilentlyContinue
|
||||||
|
L 'IE ESC disabled'
|
||||||
|
|
||||||
|
# ── Hibernate off (frees ~RAM-size on C:, no need on VMs) ────────────────
|
||||||
|
powercfg /h off 2>&1 | Out-Null
|
||||||
|
# Power plan High Performance — never sleep, never throttle CPU
|
||||||
|
powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c 2>&1 | Out-Null
|
||||||
|
powercfg /change monitor-timeout-ac 0 | Out-Null
|
||||||
|
powercfg /change disk-timeout-ac 0 | Out-Null
|
||||||
|
powercfg /change standby-timeout-ac 0 | Out-Null
|
||||||
|
powercfg /change hibernate-timeout-ac 0 | Out-Null
|
||||||
|
L 'Hibernate off, power plan High Performance'
|
||||||
|
|
||||||
|
# ── Unneeded services off (CI build VM, not a print/search/file server) ──
|
||||||
|
foreach (`$svc in 'WSearch','Spooler','SysMain','WerSvc','RemoteRegistry','Fax','MapsBroker','RetailDemo','XblAuthManager','XblGameSave','XboxNetApiSvc','XboxGipSvc') {
|
||||||
|
`$s = Get-Service -Name `$svc -ErrorAction SilentlyContinue
|
||||||
|
if (`$s) {
|
||||||
|
sc.exe config `$svc start= disabled | Out-Null
|
||||||
|
sc.exe stop `$svc | Out-Null
|
||||||
|
L " service disabled: `$svc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Windows Customer Experience tasks off ────────────────────────────────
|
||||||
|
foreach (`$task in @(
|
||||||
|
'\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser',
|
||||||
|
'\Microsoft\Windows\Application Experience\ProgramDataUpdater',
|
||||||
|
'\Microsoft\Windows\Autochk\Proxy',
|
||||||
|
'\Microsoft\Windows\Customer Experience Improvement Program\Consolidator',
|
||||||
|
'\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip',
|
||||||
|
'\Microsoft\Windows\Feedback\Siuf\DmClient',
|
||||||
|
'\Microsoft\Windows\Feedback\Siuf\DmClientOnScenarioDownload'
|
||||||
|
)) {
|
||||||
|
schtasks /Change /TN `$task /Disable 2>&1 | Out-Null
|
||||||
|
}
|
||||||
|
L 'CEIP / feedback scheduled tasks disabled'
|
||||||
|
|
||||||
|
# ── Explorer UX: show file extensions + open to This PC ──────────────────
|
||||||
|
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v HideFileExt /t REG_DWORD /d 0 /f | Out-Null
|
||||||
|
reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v LaunchTo /t REG_DWORD /d 1 /f | Out-Null
|
||||||
|
L 'Explorer UX tweaks applied (HKCU)'
|
||||||
|
|
||||||
|
# ── Wait for VMware Tools install to settle (kicked off at top) ──────────
|
||||||
|
# Wrapper returned immediately while msiexec runs async. Poll until:
|
||||||
|
# (a) no setup/setup64/vminst wrapper process (excludes system msiexec /V)
|
||||||
|
# (b) vmtoolsd.exe on disk
|
||||||
|
# (c) VMTools service Running (not just registered — install finalized)
|
||||||
|
# Hard timeout 15 min.
|
||||||
|
if (`$toolsExe) {
|
||||||
|
`$installDeadline = (Get-Date).AddMinutes(15)
|
||||||
|
while ((Get-Date) -lt `$installDeadline) {
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
`$busy = Get-Process -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { `$_.ProcessName -in 'setup','setup64','VMware-Tools','VMware-Tools-Installer','vminst' }
|
||||||
|
`$vmtoolsd = Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe'
|
||||||
|
`$svc = Get-Service -Name VMTools -ErrorAction SilentlyContinue
|
||||||
|
`$svcRunning = (`$svc -and `$svc.Status -eq 'Running')
|
||||||
|
L " install poll: busy=`$(`$busy.Count) vmtoolsd=`$vmtoolsd svc=`$(if (`$svc) { `$svc.Status } else { 'absent' })"
|
||||||
|
if (-not `$busy -and `$vmtoolsd -and `$svcRunning) { L 'Tools install settled.'; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path `$msiLog) {
|
||||||
|
L "---- MSI log tail (last 30 lines) ----"
|
||||||
|
Get-Content `$msiLog -Tail 30 | ForEach-Object { L `$_ }
|
||||||
|
L '---- end MSI log ----'
|
||||||
|
} else {
|
||||||
|
L "MSI log NOT created at `$msiLog (wrapper failed before MSI launched)"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe') {
|
||||||
|
L 'vmtoolsd.exe present -> Tools installed OK'
|
||||||
|
} else {
|
||||||
|
L "WARNING: vmtoolsd.exe NOT present -> install failed (wrapper exit `$exitCode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Drop completion flag (host polls for this via vmrun) ──────────────────
|
||||||
|
New-Item -ItemType File -Path 'C:\Windows\Temp\install_complete.flag' -Force | Out-Null
|
||||||
|
L 'install_complete.flag dropped'
|
||||||
|
|
||||||
|
# ── Reboot to apply Tools/UAC/services state cleanly ──────────────────────
|
||||||
|
L 'rebooting in 15s'
|
||||||
|
shutdown /r /t 15 /f /c "post-install complete"
|
||||||
|
"@
|
||||||
|
$psOut = Join-Path $stagingDir 'post-install.ps1'
|
||||||
|
Set-Content -LiteralPath $psOut -Value $postInstall -Encoding UTF8
|
||||||
|
Assert-Step 'Render' 'post-install.ps1 written' { Test-Path $psOut }
|
||||||
|
|
||||||
|
# Bundle VMware Tools contents under autounattend\Tools\ so post-install.ps1
|
||||||
|
# always finds setup64.exe on the same CD it boots from. Avoids the
|
||||||
|
# "third SATA CD-ROM disconnected at first logon" failure mode.
|
||||||
|
Write-Host ' Extracting VMware Tools ISO into autounattend staging...' -ForegroundColor Yellow
|
||||||
|
$toolsStage = Join-Path $stagingDir 'Tools'
|
||||||
|
New-Item -ItemType Directory -Path $toolsStage -Force | Out-Null
|
||||||
|
$toolsMount = Mount-DiskImage -ImagePath $ToolsISO -PassThru -Access ReadOnly
|
||||||
|
try {
|
||||||
|
$toolsLetter = "$(($toolsMount | Get-Volume).DriveLetter):\"
|
||||||
|
Copy-Item -Path (Join-Path $toolsLetter '*') -Destination $toolsStage -Recurse -Force
|
||||||
|
} finally {
|
||||||
|
Dismount-DiskImage -ImagePath $ToolsISO -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
Assert-Step 'Render' 'Tools setup bundled in staging\Tools' {
|
||||||
|
(Test-Path (Join-Path $toolsStage 'setup.exe')) -or
|
||||||
|
(Test-Path (Join-Path $toolsStage 'setup64.exe'))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 3: build autounattend ISO
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 3: build autounattend ISO via IMAPI2FS'
|
||||||
|
|
||||||
|
New-IsoFromFolder -SourceFolder $stagingDir -IsoPath $autounattendIso -VolumeLabel 'AUTOUNATTEND'
|
||||||
|
Assert-Step 'IsoBuild' "autounattend.iso created at $autounattendIso" { Test-Path $autounattendIso }
|
||||||
|
Assert-Step 'IsoBuild' 'autounattend.iso non-empty' { (Get-Item $autounattendIso).Length -gt 4096 }
|
||||||
|
|
||||||
|
# Cleanup staging — only ISO matters from here on. IMAPI2FS may hold a
|
||||||
|
# transient lock on bundled binaries; force GC then retry, then ignore.
|
||||||
|
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
if (Test-Path $stagingDir) {
|
||||||
|
Write-Host " [warn] staging dir not fully cleaned, leaving in place: $stagingDir" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 4: create VMDK
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step "Step 4: create $DiskSizeGB GB VMDK (single-file growable)"
|
||||||
|
|
||||||
|
# -t 0 = single growable file (thin); -a lsilogic affects descriptor only,
|
||||||
|
# the disk attaches to the NVMe controller defined in the VMX.
|
||||||
|
$vdiskArgs = @('-c', '-s', "${DiskSizeGB}GB", '-a', 'lsilogic', '-t', '0', $vmdkPath)
|
||||||
|
& $vdiskMgr @vdiskArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "vmware-vdiskmanager failed (exit $LASTEXITCODE)" }
|
||||||
|
Assert-Step 'Vmdk' "VMDK file present: $vmdkPath" { Test-Path $vmdkPath }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 4b: rebuild Windows ISO with EFI no-prompt boot image
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 4b: build no-prompt Windows ISO (skips "Press any key")'
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($WinISONoPromptPath)) {
|
||||||
|
$srcDir = Split-Path -Parent $WinISO
|
||||||
|
$srcBase = [IO.Path]::GetFileNameWithoutExtension($WinISO)
|
||||||
|
$WinISONoPromptPath = Join-Path $srcDir "$srcBase.patched.iso"
|
||||||
|
}
|
||||||
|
|
||||||
|
$srcMtime = (Get-Item $WinISO).LastWriteTime
|
||||||
|
$rebuild = $true
|
||||||
|
if (Test-Path $WinISONoPromptPath) {
|
||||||
|
if ((Get-Item $WinISONoPromptPath).LastWriteTime -ge $srcMtime) {
|
||||||
|
Write-Host " Cached no-prompt ISO is current, reusing: $WinISONoPromptPath" -ForegroundColor Green
|
||||||
|
$rebuild = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($rebuild) {
|
||||||
|
Write-Host " Building no-prompt ISO from $WinISO" -ForegroundColor Yellow
|
||||||
|
Write-Host " Output: $WinISONoPromptPath" -ForegroundColor Yellow
|
||||||
|
New-WinIsoNoPrompt -SourceIsoPath $WinISO -OutputIsoPath $WinISONoPromptPath
|
||||||
|
}
|
||||||
|
Assert-Step 'NoPromptIso' "no-prompt ISO present: $WinISONoPromptPath" { Test-Path $WinISONoPromptPath }
|
||||||
|
Assert-Step 'NoPromptIso' 'no-prompt ISO non-empty (>500 MB)' { (Get-Item $WinISONoPromptPath).Length -gt 500MB }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 5: generate VMX
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 5: generate VMX'
|
||||||
|
|
||||||
|
# guestOS:
|
||||||
|
# "windows2019srvnext-64" is the generic forward-compat ID for new Windows
|
||||||
|
# Server releases on Workstation. Workstation 17.6+ accepts it for Server 2025.
|
||||||
|
# If your Workstation version supports the explicit ID, change it here.
|
||||||
|
$guestOS = 'windows2019srvnext-64'
|
||||||
|
|
||||||
|
# CD-ROM ordering (sata):
|
||||||
|
# sata0:0 = Windows install ISO (bootable) ← EFI picks this on first boot
|
||||||
|
# sata0:1 = autounattend ISO (non-bootable, scanned by Setup)
|
||||||
|
# sata0:2 = VMware Tools ISO (Tools installer source)
|
||||||
|
$vmxLines = @(
|
||||||
|
'.encoding = "windows-1252"',
|
||||||
|
'config.version = "8"',
|
||||||
|
'virtualHW.version = "21"',
|
||||||
|
"displayName = `"$VMName`"",
|
||||||
|
"guestOS = `"$guestOS`"",
|
||||||
|
'firmware = "efi"',
|
||||||
|
'uefi.secureBoot.enabled = "FALSE"',
|
||||||
|
"memSize = `"$MemoryMB`"",
|
||||||
|
"numvcpus = `"$VCPUCount`"",
|
||||||
|
"cpuid.coresPerSocket = `"$CoresPerSocket`"",
|
||||||
|
'tools.syncTime = "TRUE"',
|
||||||
|
'tools.upgrade.policy = "manual"',
|
||||||
|
'powerType.powerOff = "soft"',
|
||||||
|
'powerType.powerOn = "soft"',
|
||||||
|
'powerType.suspend = "soft"',
|
||||||
|
'powerType.reset = "soft"',
|
||||||
|
'uuid.action = "create"',
|
||||||
|
'',
|
||||||
|
'# PCIe root ports — required for explicit PCI slot assignments below.',
|
||||||
|
'# Each pcieRootPort exposes 8 slots → enough headroom for NVMe + SATA + NIC.',
|
||||||
|
'pciBridge0.present = "TRUE"',
|
||||||
|
'pciBridge4.present = "TRUE"',
|
||||||
|
'pciBridge4.virtualDev = "pcieRootPort"',
|
||||||
|
'pciBridge4.functions = "8"',
|
||||||
|
'pciBridge5.present = "TRUE"',
|
||||||
|
'pciBridge5.virtualDev = "pcieRootPort"',
|
||||||
|
'pciBridge5.functions = "8"',
|
||||||
|
'pciBridge6.present = "TRUE"',
|
||||||
|
'pciBridge6.virtualDev = "pcieRootPort"',
|
||||||
|
'pciBridge6.functions = "8"',
|
||||||
|
'pciBridge7.present = "TRUE"',
|
||||||
|
'pciBridge7.virtualDev = "pcieRootPort"',
|
||||||
|
'pciBridge7.functions = "8"',
|
||||||
|
'',
|
||||||
|
'# NVMe controller + 80 GB system disk',
|
||||||
|
'nvme0.present = "TRUE"',
|
||||||
|
'nvme0.pciSlotNumber = "224"',
|
||||||
|
'nvme0:0.present = "TRUE"',
|
||||||
|
"nvme0:0.fileName = `"$vmdkName`"",
|
||||||
|
'nvme0:0.deviceType = "disk"',
|
||||||
|
'',
|
||||||
|
'# Three SATA CD-ROMs: install / autounattend / Tools',
|
||||||
|
'sata0.present = "TRUE"',
|
||||||
|
'sata0.pciSlotNumber = "32"',
|
||||||
|
'sata0:0.present = "TRUE"',
|
||||||
|
'sata0:0.deviceType = "cdrom-image"',
|
||||||
|
"sata0:0.fileName = `"$WinISONoPromptPath`"",
|
||||||
|
'sata0:0.startConnected = "TRUE"',
|
||||||
|
'sata0:1.present = "TRUE"',
|
||||||
|
'sata0:1.deviceType = "cdrom-image"',
|
||||||
|
"sata0:1.fileName = `"$autounattendIso`"",
|
||||||
|
'sata0:1.startConnected = "TRUE"',
|
||||||
|
'sata0:2.present = "TRUE"',
|
||||||
|
'sata0:2.deviceType = "cdrom-image"',
|
||||||
|
"sata0:2.fileName = `"$ToolsISO`"",
|
||||||
|
'sata0:2.startConnected = "TRUE"',
|
||||||
|
'',
|
||||||
|
'# NIC: e1000e for install (broad compat); switched to vmxnet3 after Tools',
|
||||||
|
'ethernet0.present = "TRUE"',
|
||||||
|
'ethernet0.virtualDev = "e1000e"',
|
||||||
|
'ethernet0.connectionType = "nat"',
|
||||||
|
'ethernet0.addressType = "generated"',
|
||||||
|
'ethernet0.startConnected = "TRUE"',
|
||||||
|
'ethernet0.pciSlotNumber = "192"',
|
||||||
|
'',
|
||||||
|
'# USB / video',
|
||||||
|
'usb.present = "TRUE"',
|
||||||
|
'usb.pciSlotNumber = "34"',
|
||||||
|
'ehci.present = "TRUE"',
|
||||||
|
'ehci.pciSlotNumber = "35"',
|
||||||
|
'usb_xhci.present = "TRUE"',
|
||||||
|
'usb_xhci.pciSlotNumber = "33"',
|
||||||
|
'svga.present = "TRUE"',
|
||||||
|
'svga.graphicsMemoryKB = "262144"',
|
||||||
|
'',
|
||||||
|
'# No floppy / no sound (CI VM)',
|
||||||
|
'floppy0.present = "FALSE"',
|
||||||
|
'sound.present = "FALSE"',
|
||||||
|
'',
|
||||||
|
'# Misc',
|
||||||
|
'vmci0.present = "TRUE"',
|
||||||
|
'vmci0.pciSlotNumber = "36"',
|
||||||
|
'hpet0.present = "TRUE"',
|
||||||
|
'mks.enable3d = "FALSE"',
|
||||||
|
'snapshot.action = "keep"'
|
||||||
|
)
|
||||||
|
Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII
|
||||||
|
Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 6: power on VM
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 6: power on VM (vmrun start nogui)'
|
||||||
|
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
$running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||||
|
Assert-Step 'PowerOn' 'VM appears in vmrun list' { $running -match [regex]::Escape($VMXPath) }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 7: wait for install_complete.flag inside guest
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step "Step 7: wait for guest install_complete.flag (max ${GuestPollMaxMinutes} min)"
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddMinutes($GuestPollMaxMinutes)
|
||||||
|
$flagPath = 'C:\Windows\Temp\install_complete.flag'
|
||||||
|
$flagSeen = $false
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
Start-Sleep -Seconds $GuestPollSeconds
|
||||||
|
$toolsState = Invoke-Vmrun -Arguments @('-T','ws','checkToolsState',$VMXPath) -IgnoreErrors
|
||||||
|
Write-Host (" [{0:HH:mm:ss}] toolsState={1}" -f (Get-Date), ($toolsState -join ' '))
|
||||||
|
if ($toolsState -notmatch 'running') { continue }
|
||||||
|
|
||||||
|
# Tools running → can probe filesystem with guest creds.
|
||||||
|
$probe = & $vmrunPath -T ws -gu $AdminUser -gp $AdminPassword `
|
||||||
|
fileExistsInGuest $VMXPath $flagPath 2>&1
|
||||||
|
Write-Host (" fileExistsInGuest -> {0}" -f ($probe -join ' '))
|
||||||
|
if ($probe -match 'The file exists') { $flagSeen = $true; break }
|
||||||
|
}
|
||||||
|
Assert-Step 'GuestReady' "install_complete.flag observed in guest" { $flagSeen }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 8: graceful soft-stop, swap NIC e1000e → vmxnet3
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 8: stop VM softly, swap NIC to vmxnet3'
|
||||||
|
|
||||||
|
Invoke-Vmrun -Arguments @('-T','ws','stop',$VMXPath,'soft') -IgnoreErrors | Out-Null
|
||||||
|
|
||||||
|
# Wait for VM to drop out of running list (max 3 min).
|
||||||
|
$stopDeadline = (Get-Date).AddMinutes(3)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
$still = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||||
|
} while ((Get-Date) -lt $stopDeadline -and ($still -match [regex]::Escape($VMXPath)))
|
||||||
|
Assert-Step 'Stop' 'VM no longer in vmrun list' {
|
||||||
|
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||||
|
-not ($list -match [regex]::Escape($VMXPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-VmxKey -VmxPath $VMXPath -Key 'ethernet0.virtualDev' -Value 'vmxnet3'
|
||||||
|
Assert-Step 'NicSwap' 'ethernet0.virtualDev = vmxnet3' {
|
||||||
|
(Get-Content -LiteralPath $VMXPath) -match '^\s*ethernet0\.virtualDev\s*=\s*"vmxnet3"\s*$'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 9: power on, verify vmxnet3 + DHCP IP
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Step 9: power on with vmxnet3, verify DHCP IP'
|
||||||
|
|
||||||
|
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null
|
||||||
|
$ipDeadline = (Get-Date).AddMinutes(10)
|
||||||
|
$guestIP = $null
|
||||||
|
do {
|
||||||
|
Start-Sleep -Seconds 10
|
||||||
|
$guestIP = Invoke-Vmrun -Arguments @('-T','ws','getGuestIPAddress',$VMXPath,'-wait') -IgnoreErrors
|
||||||
|
Write-Host " guestIP probe -> $guestIP"
|
||||||
|
} while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+'))
|
||||||
|
Assert-Step 'IPCheck' 'Guest IP obtained on vmxnet3' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Step 10: shutdown → snapshot (cold) → power on
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step "Step 10: graceful shutdown, snapshot '$SnapshotName' (cold), power on"
|
||||||
|
|
||||||
|
Invoke-Vmrun -Arguments @('-T','ws','stop',$VMXPath,'soft') -IgnoreErrors | Out-Null
|
||||||
|
$stopDeadline = (Get-Date).AddMinutes(3)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
$running = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||||
|
} while ((Get-Date) -lt $stopDeadline -and ($running -match [regex]::Escape($VMXPath)))
|
||||||
|
Assert-Step 'Snapshot' 'VM powered off before snapshot' {
|
||||||
|
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||||
|
-not ($list -match [regex]::Escape($VMXPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-Vmrun -Arguments @('-T','ws','snapshot',$VMXPath,$SnapshotName) | Out-Null
|
||||||
|
$snaps = Invoke-Vmrun -Arguments @('-T','ws','listSnapshots',$VMXPath)
|
||||||
|
Assert-Step 'Snapshot' "Snapshot '$SnapshotName' present" { $snaps -match [regex]::Escape($SnapshotName) }
|
||||||
|
|
||||||
|
Invoke-Vmrun -Arguments @('-T','ws','start',$VMXPath,$vmrunUiMode) | Out-Null
|
||||||
|
$ipDeadline = (Get-Date).AddMinutes(5)
|
||||||
|
do {
|
||||||
|
Start-Sleep -Seconds 10
|
||||||
|
$guestIP = Invoke-Vmrun -Arguments @('-T','ws','getGuestIPAddress',$VMXPath,'-wait') -IgnoreErrors
|
||||||
|
} while ((Get-Date) -lt $ipDeadline -and -not ($guestIP -match '^\d+\.\d+\.\d+\.\d+'))
|
||||||
|
Assert-Step 'Snapshot' 'VM back online after snapshot' { $guestIP -match '^\d+\.\d+\.\d+\.\d+' }
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Final summary
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
Write-Step 'Done'
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " VMX : $VMXPath"
|
||||||
|
Write-Host " VMDK : $vmdkPath"
|
||||||
|
Write-Host " Hostname : $ComputerName"
|
||||||
|
Write-Host " Guest IP : $guestIP"
|
||||||
|
Write-Host " Admin user : $AdminUser"
|
||||||
|
Write-Host " Snapshot : $SnapshotName"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " RDP : mstsc /v:$guestIP"
|
||||||
|
Write-Host " WinRM HTTP : 5985"
|
||||||
|
Write-Host " WinRM HTTPS : 5986 (self-signed)"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " VM is powered on with vmxnet3 + DHCP. Live snapshot taken." -ForegroundColor Green
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
HOST-side script: delivers and runs Setup-TemplateVM.ps1 inside the template VM.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
Automates the template VM provisioning from the host machine:
|
|
||||||
1. Verifies the VM is reachable via WinRM (must already be enabled in guest)
|
|
||||||
2. Copies Setup-TemplateVM.ps1 into the VM via WinRM
|
|
||||||
3. Runs Setup-TemplateVM.ps1 inside the VM with the given parameters
|
|
||||||
4. Handles the reboot-required case (exit 3010) and optionally re-runs
|
|
||||||
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
|
|
||||||
-Password 'CIBuild!ChangeMe2026' -Persist LocalMachine
|
|
||||||
|
|
||||||
.PARAMETER VMIPAddress
|
|
||||||
IP address of the template VM while it is on NAT (VMnet8).
|
|
||||||
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.
|
|
||||||
Default: CIBuild!ChangeMe2026 — CHANGE THIS before production use.
|
|
||||||
|
|
||||||
.PARAMETER DotNetSdkVersion
|
|
||||||
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
|
|
||||||
|
|
||||||
.PARAMETER SkipWindowsUpdate
|
|
||||||
Pass through to Setup-TemplateVM.ps1 to skip the Windows Update step.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
# Interactive (prompts for admin password):
|
|
||||||
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130
|
|
||||||
|
|
||||||
# With explicit credentials:
|
|
||||||
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 `
|
|
||||||
-AdminPassword 'YourAdminPass' -BuildPassword 'StrongCIPass!2026'
|
|
||||||
|
|
||||||
# Skip Windows Update (already applied):
|
|
||||||
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 -SkipWindowsUpdate
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)]
|
|
||||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
|
||||||
[string] $VMIPAddress,
|
|
||||||
|
|
||||||
[string] $AdminUsername = 'Administrator',
|
|
||||||
|
|
||||||
[string] $AdminPassword = '',
|
|
||||||
|
|
||||||
[string] $BuildPassword = 'CIBuild!ChangeMe2026',
|
|
||||||
|
|
||||||
[string] $DotNetSdkVersion = '10.0',
|
|
||||||
|
|
||||||
[switch] $SkipWindowsUpdate
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
||||||
|
|
||||||
# ── 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 (required for Basic auth over HTTP) ──────
|
|
||||||
# These settings apply to this machine (the host), not the VM.
|
|
||||||
# AllowUnencrypted is needed for HTTP/5985 Basic auth in a lab environment.
|
|
||||||
Write-Host "[Prepare] Configuring host WinRM client for unencrypted Basic auth..."
|
|
||||||
try {
|
|
||||||
Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force -ErrorAction Stop
|
|
||||||
# Add VM IP to TrustedHosts if not already present
|
|
||||||
$currentTrusted = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
|
||||||
if ($currentTrusted -ne '*' -and $currentTrusted -notlike "*$VMIPAddress*") {
|
|
||||||
$newTrusted = if ($currentTrusted -eq '') { $VMIPAddress } else { "$currentTrusted,$VMIPAddress" }
|
|
||||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
|
|
||||||
}
|
|
||||||
Write-Host "[Prepare] Host WinRM client configured."
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Warning "[Prepare] Could not configure host WinRM client settings (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 {
|
|
||||||
Write-Error @"
|
|
||||||
[Prepare] Cannot reach $VMIPAddress via WinRM.
|
|
||||||
|
|
||||||
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
|
|
||||||
- The IP is correct (check ipconfig inside the VM)
|
|
||||||
|
|
||||||
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-TemplateVM.ps1 into the VM ─────────────────────────────────
|
|
||||||
$setupScript = Join-Path $scriptDir 'Setup-TemplateVM.ps1'
|
|
||||||
$guestScriptPath = 'C:\CI\Setup-TemplateVM.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[Prepare] Copying Setup-TemplateVM.ps1 -> guest $guestScriptPath ..."
|
|
||||||
Copy-Item -Path $setupScript -Destination $guestScriptPath -ToSession $session -Force
|
|
||||||
|
|
||||||
# ── Build argument list for Setup-TemplateVM.ps1 ─────────────────────────
|
|
||||||
$setupArgs = @{
|
|
||||||
BuildPassword = $BuildPassword
|
|
||||||
DotNetSdkVersion = $DotNetSdkVersion
|
|
||||||
}
|
|
||||||
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
|
|
||||||
|
|
||||||
# ── Run Setup-TemplateVM.ps1 inside the VM ────────────────────────────────
|
|
||||||
Write-Host "[Prepare] Running Setup-TemplateVM.ps1 inside VM (this will take a while)..."
|
|
||||||
Write-Host "[Prepare] Output from guest:"
|
|
||||||
Write-Host "------------------------------------------------------------"
|
|
||||||
|
|
||||||
$result = 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 $guestScriptPath, $setupArgs
|
|
||||||
|
|
||||||
Write-Host "------------------------------------------------------------"
|
|
||||||
|
|
||||||
# ── Handle reboot required (exit 3010) ───────────────────────────────────
|
|
||||||
if ($result -eq 3010) {
|
|
||||||
Write-Host ""
|
|
||||||
Write-Warning "*** REBOOT REQUIRED after Windows Update. ***"
|
|
||||||
Write-Warning " The VM will reboot. Wait for it to come back up, then run:"
|
|
||||||
Write-Warning " .\Prepare-TemplateSetup.ps1 -VMIPAddress $VMIPAddress -SkipWindowsUpdate"
|
|
||||||
|
|
||||||
# Reboot the VM
|
|
||||||
Write-Host "[Prepare] Sending reboot command to VM..."
|
|
||||||
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force }
|
|
||||||
exit 3010
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($result -ne 0) {
|
|
||||||
throw "Setup-TemplateVM.ps1 exited with code $result"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Success ───────────────────────────────────────────────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "============================================================" -ForegroundColor Green
|
|
||||||
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
|
|
||||||
Write-Host "============================================================" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " NOW DO THESE STEPS (manual):"
|
|
||||||
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:"
|
|
||||||
Write-Host " VM -> Snapshot -> Take Snapshot"
|
|
||||||
Write-Host " Name it EXACTLY: BaseClean"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 3. Store CI credentials on this HOST:"
|
|
||||||
Write-Host " (run in elevated PowerShell with CredentialManager module)"
|
|
||||||
Write-Host " New-StoredCredential -Target 'BuildVMGuest' ``"
|
|
||||||
Write-Host " -UserName 'ci_build' -Password '$BuildPassword' ``"
|
|
||||||
Write-Host " -Persist LocalMachine"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 4. Verify runner online in Gitea: http://10.10.20.11:3100"
|
|
||||||
Write-Host " Admin -> Runners -> local-windows-runner -> should show Online"
|
|
||||||
Write-Host ""
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,669 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
HOST-side script: delivers and runs Setup-WinBuild2025.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/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
|
||||||
|
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
|
||||||
|
(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 WinRM client settings 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-WinBuild2025.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-WinBuild2025.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-WinBuild2025.ps1 `
|
||||||
|
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
|
||||||
|
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
||||||
|
|
||||||
|
# Skip Windows Update (already applied):
|
||||||
|
.\Prepare-WinBuild2025.ps1 `
|
||||||
|
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
|
||||||
|
-SkipWindowsUpdate -StoreCredential
|
||||||
|
|
||||||
|
# Explicit IP (manual start, no VMware Tools required for IP detection):
|
||||||
|
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.130 `
|
||||||
|
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
||||||
|
|
||||||
|
# Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress:
|
||||||
|
.\Prepare-WinBuild2025.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-WinBuild2025.ps1 present alongside this script' {
|
||||||
|
Test-Path (Join-Path $scriptDir 'Setup-WinBuild2025.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`:5985 (timeout ${winrmTimeoutSec}s)..."
|
||||||
|
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
|
||||||
|
$winrmReady = $false
|
||||||
|
while ([datetime]::UtcNow -lt $winrmDeadline) {
|
||||||
|
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5985 `
|
||||||
|
-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 }
|
||||||
|
|
||||||
|
# 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 (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
|
||||||
|
$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'
|
||||||
|
}
|
||||||
|
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 "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 {
|
||||||
|
Write-Error @"
|
||||||
|
[Prepare] Cannot reach $VMIPAddress via WinRM.
|
||||||
|
|
||||||
|
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
|
||||||
|
- The IP is correct (check ipconfig inside the VM)
|
||||||
|
|
||||||
|
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'
|
||||||
|
$guestScriptPath = 'C:\CI\Setup-WinBuild2025.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-WinBuild2025.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-WinBuild2025.ps1 ───────────────────────
|
||||||
|
$setupArgs = @{
|
||||||
|
BuildPassword = $BuildPassword
|
||||||
|
BuildUsername = $BuildUsername
|
||||||
|
DotNetSdkVersion = $DotNetSdkVersion
|
||||||
|
}
|
||||||
|
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
|
||||||
|
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
|
||||||
|
|
||||||
|
# ── Run Setup-WinBuild2025.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 {
|
||||||
|
$r = Test-WSMan -ComputerName $IP -Credential $Cred `
|
||||||
|
-Authentication Basic -ErrorAction Stop
|
||||||
|
if ($r) { return $true }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[Prepare] Running Setup-WinBuild2025.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 = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
|
||||||
|
|
||||||
|
Write-Host "------------------------------------------------------------"
|
||||||
|
Write-Host "[Prepare] Iteration $iter exit code: $result"
|
||||||
|
|
||||||
|
if ($result -eq 0) { break }
|
||||||
|
|
||||||
|
if ($result -ne 3010) {
|
||||||
|
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.
|
||||||
|
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..."
|
||||||
|
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 -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-WinBuild2025.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 WinRM client settings to their original values
|
||||||
|
if ($null -ne $prevAllowUnencrypted) {
|
||||||
|
Set-Item WSMan:\localhost\Client\AllowUnencrypted $prevAllowUnencrypted -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
if ($null -ne $prevTrustedHosts) {
|
||||||
|
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
Write-Host "[Prepare] Host WinRM client settings restored."
|
||||||
|
}
|
||||||
@@ -1,659 +0,0 @@
|
|||||||
#Requires -RunAsAdministrator
|
|
||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Provisions a Windows VM as a CI build template (run INSIDE the VM once).
|
|
||||||
|
|
||||||
.DESCRIPTION
|
|
||||||
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:
|
|
||||||
- Windows Updates (all available, via COM API — no external module needed)
|
|
||||||
- WinRM with basic authentication (for PSSession from host)
|
|
||||||
- A dedicated local build user account
|
|
||||||
- Visual Studio Build Tools 2026 (MSBuild, MSVC, .NET desktop targets)
|
|
||||||
- .NET SDK 10.x
|
|
||||||
- Firewall rules for WinRM
|
|
||||||
|
|
||||||
NOTE: Git is NOT installed. The host clones the repository and copies
|
|
||||||
the source tree into the VM via WinRM (Option B isolation model).
|
|
||||||
|
|
||||||
╔══════════════════════════════════════════════════════════════════════╗
|
|
||||||
║ NETWORK REQUIREMENT DURING SETUP ║
|
|
||||||
║ The VM must have internet access while this script runs so it can ║
|
|
||||||
║ download Windows Updates and tool installers. Configure the VM NIC ║
|
|
||||||
║ to VMnet8 (NAT) before running setup, then switch back to VMnet11 ║
|
|
||||||
║ (Host-Only) BEFORE taking the BaseClean snapshot. ║
|
|
||||||
╚══════════════════════════════════════════════════════════════════════╝
|
|
||||||
|
|
||||||
After this script completes:
|
|
||||||
1. Switch VM NIC from VMnet8 (NAT) → VMnet11 (Host-Only) in VMware
|
|
||||||
Workstation: VM → Settings → Network Adapter → Custom: VMnet11
|
|
||||||
2. Shut down the VM (Start → Shut down)
|
|
||||||
3. In VMware Workstation: VM → Snapshot → Take Snapshot
|
|
||||||
Name it exactly: BaseClean
|
|
||||||
4. Power off the VM and leave it powered off permanently
|
|
||||||
|
|
||||||
.PARAMETER SkipWindowsUpdate
|
|
||||||
Skip the Windows Update step (useful if updates were already applied).
|
|
||||||
|
|
||||||
.NOTES
|
|
||||||
Invoke via Prepare-TemplateSetup.ps1 from the HOST (recommended), or
|
|
||||||
run manually from an elevated PowerShell session INSIDE the VM:
|
|
||||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
|
||||||
.\Setup-TemplateVM.ps1
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
# Local username for the CI build account inside the VM
|
|
||||||
[string] $BuildUsername = 'ci_build',
|
|
||||||
|
|
||||||
# Password for the build account.
|
|
||||||
# IMPORTANT: Change this to a strong password and store it in
|
|
||||||
# Windows Credential Manager on the HOST as target "BuildVMGuest".
|
|
||||||
[string] $BuildPassword = 'CIBuild!ChangeMe2026',
|
|
||||||
|
|
||||||
# .NET SDK channel to install (should match the host SDK version)
|
|
||||||
[string] $DotNetSdkVersion = '10.0',
|
|
||||||
|
|
||||||
# Python version to install (x.y.z — must match an exact release on python.org)
|
|
||||||
[string] $PythonVersion = '3.13.3',
|
|
||||||
|
|
||||||
# Skip Windows Update (useful when updates already applied or no internet)
|
|
||||||
[switch] $SkipWindowsUpdate
|
|
||||||
)
|
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
function Write-Step {
|
|
||||||
param([string]$Message)
|
|
||||||
Write-Host "`n=== $Message ===" -ForegroundColor Cyan
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 0: Windows Update ────────────────────────────────────────────────────
|
|
||||||
# NOTE: The Windows Update COM API (Microsoft.Update.Session) raises E_ACCESSDENIED
|
|
||||||
# when called from a WinRM/network-logon session. We work around this by running
|
|
||||||
# the actual download+install inside a Scheduled Task (SYSTEM account, full token).
|
|
||||||
if ($SkipWindowsUpdate) {
|
|
||||||
Write-Host "[Setup] Skipping Windows Update (SkipWindowsUpdate switch set)."
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Step "Running Windows Update via Scheduled Task (avoids WinRM token restrictions)"
|
|
||||||
|
|
||||||
# --- worker script that runs as SYSTEM inside the scheduled task ---
|
|
||||||
$wuWorkerPath = 'C:\CI\wu_worker.ps1'
|
|
||||||
$wuResultPath = 'C:\CI\wu_result.txt'
|
|
||||||
$wuRebootPath = 'C:\CI\wu_reboot.txt'
|
|
||||||
$wuLogPath = 'C:\CI\wu_log.txt'
|
|
||||||
|
|
||||||
$wuWorker = @'
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
try {
|
|
||||||
$s = New-Object -ComObject Microsoft.Update.Session
|
|
||||||
$q = $s.CreateUpdateSearcher()
|
|
||||||
$found = $q.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
|
|
||||||
$count = $found.Updates.Count
|
|
||||||
if ($count -eq 0) {
|
|
||||||
"0" | Set-Content C:\CI\wu_result.txt
|
|
||||||
"False" | Set-Content C:\CI\wu_reboot.txt
|
|
||||||
"No updates needed." | Set-Content C:\CI\wu_log.txt
|
|
||||||
} else {
|
|
||||||
"Downloading $count update(s)..." | Set-Content C:\CI\wu_log.txt
|
|
||||||
$dl = $s.CreateUpdateDownloader()
|
|
||||||
$dl.Updates = $found.Updates
|
|
||||||
$dl.Download() | Out-Null
|
|
||||||
"Installing $count update(s)..." | Add-Content C:\CI\wu_log.txt
|
|
||||||
$inst = $s.CreateUpdateInstaller()
|
|
||||||
$inst.Updates = $found.Updates
|
|
||||||
$r = $inst.Install()
|
|
||||||
[string]$r.ResultCode | Set-Content C:\CI\wu_result.txt
|
|
||||||
[string]$r.RebootRequired | Set-Content C:\CI\wu_reboot.txt
|
|
||||||
"Done. ResultCode=$($r.ResultCode) RebootRequired=$($r.RebootRequired)" | Add-Content C:\CI\wu_log.txt
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
"ERROR: $_" | Set-Content C:\CI\wu_log.txt
|
|
||||||
"99" | Set-Content C:\CI\wu_result.txt
|
|
||||||
"False" | Set-Content C:\CI\wu_reboot.txt
|
|
||||||
}
|
|
||||||
'@
|
|
||||||
$wuWorker | Set-Content -Path $wuWorkerPath -Encoding UTF8
|
|
||||||
|
|
||||||
# Remove any leftover result files from a previous run
|
|
||||||
Remove-Item $wuResultPath, $wuRebootPath -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
# Register scheduled task running as SYSTEM
|
|
||||||
$taskName = 'CI-WindowsUpdate'
|
|
||||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
|
||||||
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$wuWorkerPath`""
|
|
||||||
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
|
|
||||||
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
|
|
||||||
|
|
||||||
Write-Host "Scheduled task registered. Starting Windows Update..."
|
|
||||||
Start-ScheduledTask -TaskName $taskName
|
|
||||||
|
|
||||||
# Poll until the task finishes (result file written = task complete)
|
|
||||||
$timeout = [datetime]::UtcNow.AddMinutes(60)
|
|
||||||
$dotCount = 0
|
|
||||||
while (-not (Test-Path $wuResultPath)) {
|
|
||||||
if ([datetime]::UtcNow -gt $timeout) {
|
|
||||||
throw "Windows Update timed out after 60 minutes."
|
|
||||||
}
|
|
||||||
Start-Sleep -Seconds 15
|
|
||||||
$dotCount++
|
|
||||||
if ($dotCount % 4 -eq 0) { Write-Host " ... still running ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read results
|
|
||||||
$resultCode = [int](Get-Content $wuResultPath -Raw).Trim()
|
|
||||||
$rebootNeeded = (Get-Content $wuRebootPath -Raw).Trim() -eq 'True'
|
|
||||||
$log = Get-Content $wuLogPath -Raw -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
Write-Host "Windows Update log:"
|
|
||||||
Write-Host $log
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item $wuWorkerPath, $wuResultPath, $wuRebootPath, $wuLogPath -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
if ($resultCode -eq 99) {
|
|
||||||
throw "Windows Update worker script failed. See log above."
|
|
||||||
}
|
|
||||||
if ($resultCode -notin @(0, 2, 3)) {
|
|
||||||
Write-Warning "Windows Update returned unexpected result code: $resultCode"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Windows Update completed (ResultCode=$resultCode)."
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($rebootNeeded) {
|
|
||||||
Write-Warning "*** REBOOT REQUIRED to finish installing updates. ***"
|
|
||||||
Write-Warning " Restart the VM, then re-run this script with: -SkipWindowsUpdate"
|
|
||||||
exit 3010
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Step 1: WinRM ─────────────────────────────────────────────────────────────
|
|
||||||
Write-Step "Configuring WinRM"
|
|
||||||
|
|
||||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null
|
|
||||||
|
|
||||||
# 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 '@{MaxMemoryPerShellMB="2048"}' | Out-Null
|
|
||||||
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."
|
|
||||||
|
|
||||||
# ── Step 2: Firewall ──────────────────────────────────────────────────────────
|
|
||||||
Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM"
|
|
||||||
|
|
||||||
# Disable all firewall profiles entirely. This VM lives on a Host-Only network
|
|
||||||
# (VMnet11) with no external exposure, so full disable is acceptable and avoids
|
|
||||||
# any rule-order or profile-classification issues that would block WinRM/ICMP.
|
|
||||||
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
|
|
||||||
Write-Host "Windows Firewall disabled on all profiles."
|
|
||||||
|
|
||||||
# ── Step 3: Build user account ────────────────────────────────────────────────
|
|
||||||
Write-Step "Creating build user account: $BuildUsername"
|
|
||||||
|
|
||||||
$existingUser = Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue
|
|
||||||
if ($existingUser) {
|
|
||||||
Write-Host "User '$BuildUsername' already exists — skipping creation."
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# Use net user instead of New-LocalUser: the LocalAccounts cmdlets have a known
|
|
||||||
# bug where SecureString deserialized over WinRM causes InvalidPasswordException.
|
|
||||||
$netResult = & net user $BuildUsername $BuildPassword /add /y 2>&1
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Failed to create user '$BuildUsername': $netResult"
|
|
||||||
}
|
|
||||||
Write-Host "User '$BuildUsername' created."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Always ensure password policy and Administrators membership (idempotent)
|
|
||||||
& net user $BuildUsername /passwordchg:no | Out-Null
|
|
||||||
Set-LocalUser -Name $BuildUsername -PasswordNeverExpires $true
|
|
||||||
|
|
||||||
$isMember = & net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername
|
|
||||||
if (-not $isMember) {
|
|
||||||
& net localgroup Administrators $BuildUsername /add | Out-Null
|
|
||||||
Write-Host "User '$BuildUsername' added to Administrators."
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "User '$BuildUsername' already in Administrators."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 3b: Disable UAC ──────────────────────────────────────────────────────
|
|
||||||
Write-Step "Disabling UAC (isolated lab VM)"
|
|
||||||
|
|
||||||
# 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."
|
|
||||||
|
|
||||||
# ── Step 4: CI working directories ───────────────────────────────────────────
|
|
||||||
Write-Step "Creating CI working directories"
|
|
||||||
|
|
||||||
foreach ($dir in @('C:\CI\build', 'C:\CI\output', 'C:\CI\scripts')) {
|
|
||||||
if (-not (Test-Path $dir)) {
|
|
||||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
|
||||||
Write-Host "Created: $dir"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 4b: Explorer settings ───────────────────────────────────────────────
|
|
||||||
Write-Step "Configuring Explorer defaults"
|
|
||||||
|
|
||||||
# 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)."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 5: Disable Windows Defender ────────────────────────────────────────
|
|
||||||
Write-Step "Disabling Windows Defender real-time protection"
|
|
||||||
|
|
||||||
# Defender scanning significantly slows compilation and NuGet restore.
|
|
||||||
# On an isolated CI template VM this trade-off is acceptable.
|
|
||||||
$defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue
|
|
||||||
if ($defSvc) {
|
|
||||||
Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null
|
|
||||||
Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null
|
|
||||||
Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null
|
|
||||||
Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null
|
|
||||||
# Exclude CI build paths from any remaining scanning
|
|
||||||
Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null
|
|
||||||
Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null
|
|
||||||
Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null
|
|
||||||
Write-Host "Windows Defender real-time protection disabled."
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Windows Defender service not found — skipping."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Disable via Group Policy registry key — survives Defender auto-updates and reboots.
|
|
||||||
# This is the same key that SCCM/GPO uses in enterprise environments.
|
|
||||||
$defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender'
|
|
||||||
if (-not (Test-Path $defenderKey)) {
|
|
||||||
New-Item -Path $defenderKey -Force | Out-Null
|
|
||||||
}
|
|
||||||
Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force
|
|
||||||
Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force
|
|
||||||
# Also disable via the Windows Security Center policy subkey
|
|
||||||
$rtKey = "$defenderKey\Real-Time Protection"
|
|
||||||
if (-not (Test-Path $rtKey)) {
|
|
||||||
New-Item -Path $rtKey -Force | Out-Null
|
|
||||||
}
|
|
||||||
Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force
|
|
||||||
Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force
|
|
||||||
Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force
|
|
||||||
Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force
|
|
||||||
Write-Host "Defender GPO registry keys written (persistent across updates)."
|
|
||||||
|
|
||||||
# ── Step 6: Install .NET SDK ─────────────────────────────────────────────────
|
|
||||||
Write-Step "Installing .NET SDK $DotNetSdkVersion"
|
|
||||||
|
|
||||||
$dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue
|
|
||||||
if ($dotnetInstalled) {
|
|
||||||
$installedVersion = dotnet --version
|
|
||||||
Write-Host ".NET SDK already installed: $installedVersion"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "Downloading dotnet-install.ps1..."
|
|
||||||
$dotnetInstallScript = "$env:TEMP\dotnet-install.ps1"
|
|
||||||
Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' `
|
|
||||||
-OutFile $dotnetInstallScript -UseBasicParsing
|
|
||||||
|
|
||||||
Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..."
|
|
||||||
& $dotnetInstallScript `
|
|
||||||
-Channel $DotNetSdkVersion `
|
|
||||||
-InstallDir 'C:\dotnet' `
|
|
||||||
-NoPath
|
|
||||||
|
|
||||||
# Add to system PATH
|
|
||||||
$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
|
|
||||||
if ($currentPath -notlike '*C:\dotnet*') {
|
|
||||||
[System.Environment]::SetEnvironmentVariable(
|
|
||||||
'PATH',
|
|
||||||
"$currentPath;C:\dotnet",
|
|
||||||
'Machine'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
$env:PATH = $env:PATH + ';C:\dotnet'
|
|
||||||
|
|
||||||
Write-Host ".NET SDK installed: $(dotnet --version)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 7: Install Python ───────────────────────────────────────────────────
|
|
||||||
Write-Step "Installing Python $PythonVersion"
|
|
||||||
|
|
||||||
$pythonExe = 'C:\Python\python.exe'
|
|
||||||
if (Test-Path $pythonExe) {
|
|
||||||
Write-Host "Python already installed: $(& $pythonExe --version 2>&1)"
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$pyInstallerUrl = "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe"
|
|
||||||
$pyInstallerPath = 'C:\CI\python_installer.exe'
|
|
||||||
Write-Host "Downloading Python $PythonVersion installer..."
|
|
||||||
Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing
|
|
||||||
|
|
||||||
Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..."
|
|
||||||
$pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @(
|
|
||||||
'/quiet',
|
|
||||||
'InstallAllUsers=1',
|
|
||||||
'PrependPath=1',
|
|
||||||
'Include_test=0',
|
|
||||||
'Include_doc=0',
|
|
||||||
'TargetDir=C:\Python'
|
|
||||||
) -Wait -PassThru
|
|
||||||
|
|
||||||
Remove-Item $pyInstallerPath -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
if ($pyProc -and $pyProc.ExitCode -ne 0) {
|
|
||||||
throw "Python installation failed (exit $($pyProc.ExitCode))."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Refresh PATH for subsequent steps
|
|
||||||
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
|
|
||||||
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
|
|
||||||
|
|
||||||
Write-Host "Python installed: $(python --version 2>&1)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 8: Install Visual Studio Build Tools 2022 ───────────────────────────
|
|
||||||
Write-Step "Installing Visual Studio Build Tools 2026"
|
|
||||||
|
|
||||||
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
|
|
||||||
if (Test-Path $msbuildPath) {
|
|
||||||
Write-Host "VS Build Tools 2026 already installed."
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$vsInstallerUrl = 'https://aka.ms/vs/stable/vs_buildtools.exe'
|
|
||||||
$vsInstallerPath = 'C:\CI\vs_BuildTools.exe'
|
|
||||||
$vsResultPath = 'C:\CI\vs_result.txt'
|
|
||||||
$vsLogPath = 'C:\CI\vs_log.txt'
|
|
||||||
|
|
||||||
Write-Host "Downloading VS Build Tools installer..."
|
|
||||||
Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy
|
|
||||||
Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing
|
|
||||||
|
|
||||||
# Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet).
|
|
||||||
# SYSTEM account cannot execute them without this unblock step.
|
|
||||||
Unblock-File -Path $vsInstallerPath -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
# Verify the download produced a valid Windows PE file (MZ header)
|
|
||||||
$mzBytes = [System.IO.File]::ReadAllBytes($vsInstallerPath) | Select-Object -First 2
|
|
||||||
if (-not ($mzBytes[0] -eq 0x4D -and $mzBytes[1] -eq 0x5A)) {
|
|
||||||
$fileSize = (Get-Item $vsInstallerPath).Length
|
|
||||||
throw "VS installer download appears invalid (size=$fileSize, not an EXE). Check the URL: $vsInstallerUrl"
|
|
||||||
}
|
|
||||||
Write-Host "Installer verified OK ($((Get-Item $vsInstallerPath).Length) bytes)."
|
|
||||||
|
|
||||||
# VS installer fails under WinRM (network logon token). Run via Scheduled Task as SYSTEM.
|
|
||||||
$vsWorkerPath = 'C:\CI\vs_worker.ps1'
|
|
||||||
$vsWorker = @"
|
|
||||||
# SYSTEM account may have no TEMP set — use C:\Windows\Temp
|
|
||||||
`$env:TEMP = 'C:\Windows\Temp'
|
|
||||||
`$env:TMP = 'C:\Windows\Temp'
|
|
||||||
|
|
||||||
# Clean any corrupt VS installer cache left by previous failed attempts
|
|
||||||
Remove-Item 'C:\ProgramData\Microsoft\VisualStudio\Packages' -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item 'C:\Windows\Temp\vs*' -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item 'C:\Windows\Temp\dd_*' -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
`$result = try {
|
|
||||||
`$proc = Start-Process -FilePath '$vsInstallerPath' -ArgumentList @(
|
|
||||||
'--quiet','--wait','--norestart','--nocache',
|
|
||||||
'--installPath "C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools"',
|
|
||||||
'--add','Microsoft.VisualStudio.Workload.VCTools',
|
|
||||||
'--includeRecommended',
|
|
||||||
'--add','Microsoft.VisualStudio.Workload.MSBuildTools',
|
|
||||||
'--add','Microsoft.VisualStudio.Workload.ManagedDesktopBuildTools',
|
|
||||||
'--add','Microsoft.VisualStudio.Component.NuGet.BuildTools',
|
|
||||||
'--add','Microsoft.Net.Component.4.8.SDK',
|
|
||||||
'--add','Microsoft.Net.Component.4.7.2.TargetingPack'
|
|
||||||
) -Wait -PassThru
|
|
||||||
if (`$proc) { [string]`$proc.ExitCode } else { '99' }
|
|
||||||
} catch {
|
|
||||||
"99: `$_"
|
|
||||||
}
|
|
||||||
if (-not `$result) { `$result = '99' }
|
|
||||||
`$result | Set-Content '$vsResultPath' -Encoding UTF8
|
|
||||||
"@
|
|
||||||
$vsWorker | Set-Content -Path $vsWorkerPath -Encoding UTF8
|
|
||||||
|
|
||||||
Remove-Item $vsResultPath -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
$taskName = 'CI-VSBuildTools'
|
|
||||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
|
||||||
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$vsWorkerPath`""
|
|
||||||
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
|
|
||||||
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
|
|
||||||
|
|
||||||
Write-Host "Installing VS Build Tools via Scheduled Task (this may take 15-30 minutes)..."
|
|
||||||
Start-ScheduledTask -TaskName $taskName
|
|
||||||
|
|
||||||
$timeout = [datetime]::UtcNow.AddMinutes(60)
|
|
||||||
$dots = 0
|
|
||||||
while (-not (Test-Path $vsResultPath)) {
|
|
||||||
if ([datetime]::UtcNow -gt $timeout) { throw "VS Build Tools installation timed out." }
|
|
||||||
# If task already finished without writing the result file, the worker itself crashed
|
|
||||||
$taskInfo = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
|
||||||
if ($taskInfo -and $taskInfo.State -eq 'Ready') {
|
|
||||||
$lastResult = (Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue).LastTaskResult
|
|
||||||
throw "VS worker task exited prematurely (LastTaskResult=0x$('{0:X}' -f $lastResult)). Check C:\CI\vs_worker.ps1 for syntax errors."
|
|
||||||
}
|
|
||||||
Start-Sleep -Seconds 20
|
|
||||||
$dots++
|
|
||||||
if ($dots % 3 -eq 0) { Write-Host " ... still installing ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawResult = Get-Content $vsResultPath -Raw -ErrorAction SilentlyContinue
|
|
||||||
$rawResult = if ($rawResult) { $rawResult.Trim() } else { '99' }
|
|
||||||
# Result file may contain "0", "3010", or "99: <error message>"
|
|
||||||
$vsExit = if ($rawResult -match '^\d+$') { [int]$rawResult } else { 99 }
|
|
||||||
$vsMsg = if ($vsExit -eq 99) { $rawResult } else { '' }
|
|
||||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item $vsWorkerPath, $vsResultPath, $vsInstallerPath -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
if ($vsExit -notin @(0, 3010)) {
|
|
||||||
throw "VS Build Tools installation failed (exit $vsExit). $vsMsg"
|
|
||||||
}
|
|
||||||
Write-Host "VS Build Tools 2026 installed (exit code $vsExit)."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add MSBuild to system PATH
|
|
||||||
$msbuildDir = Split-Path $msbuildPath -Parent
|
|
||||||
$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
|
|
||||||
if ($currentPath -notlike "*$msbuildDir*") {
|
|
||||||
[System.Environment]::SetEnvironmentVariable(
|
|
||||||
'PATH',
|
|
||||||
"$currentPath;$msbuildDir",
|
|
||||||
'Machine'
|
|
||||||
)
|
|
||||||
Write-Host "MSBuild added to system PATH."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Step 9: Verify toolchain ───────────────────────────────────────────────
|
|
||||||
Write-Step "Verifying toolchain"
|
|
||||||
|
|
||||||
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
|
|
||||||
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
|
|
||||||
|
|
||||||
$allOk = $true
|
|
||||||
|
|
||||||
# Helper: resolve a binary — tries hardcoded path first, then Get-Command (PATH)
|
|
||||||
function Resolve-Tool {
|
|
||||||
param([string]$HardcodedPath, [string]$CommandName)
|
|
||||||
if ($HardcodedPath -and (Test-Path $HardcodedPath -PathType Leaf)) {
|
|
||||||
return $HardcodedPath
|
|
||||||
}
|
|
||||||
$found = Get-Command $CommandName -ErrorAction SilentlyContinue
|
|
||||||
if ($found) { return $found.Source }
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
# dotnet
|
|
||||||
$dotnetBin = Resolve-Tool '' 'dotnet'
|
|
||||||
if ($dotnetBin) {
|
|
||||||
$v = & $dotnetBin --version 2>&1 | Select-Object -First 1
|
|
||||||
Write-Host " [OK] dotnet: $v ($dotnetBin)"
|
|
||||||
} else {
|
|
||||||
Write-Warning " [FAIL] dotnet not found"
|
|
||||||
$allOk = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
# python
|
|
||||||
$pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python'
|
|
||||||
if ($pythonBin) {
|
|
||||||
$v = & $pythonBin --version 2>&1 | Select-Object -First 1
|
|
||||||
Write-Host " [OK] python: $v ($pythonBin)"
|
|
||||||
} else {
|
|
||||||
Write-Warning " [FAIL] python not found"
|
|
||||||
$allOk = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
# msbuild
|
|
||||||
$msbuildBin = Resolve-Tool $msbuildPath 'msbuild'
|
|
||||||
if ($msbuildBin) {
|
|
||||||
$v = & $msbuildBin -version 2>&1 | Select-Object -First 1
|
|
||||||
Write-Host " [OK] msbuild: $v ($msbuildBin)"
|
|
||||||
} else {
|
|
||||||
Write-Warning " [FAIL] msbuild not found at '$msbuildPath' and not in PATH"
|
|
||||||
$allOk = $false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $allOk) {
|
|
||||||
Write-Warning "Some tools failed verification. Review above and rerun if needed."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
||||||
Write-Step "Cleaning up disk"
|
|
||||||
|
|
||||||
# 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags
|
|
||||||
# First register the flags via /sageset:1 in the registry (silent, no UI)
|
|
||||||
$cleanKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches'
|
|
||||||
$sageset = 1
|
|
||||||
$cleanCategories = @(
|
|
||||||
'Active Setup Temp Folders',
|
|
||||||
'BranchCache',
|
|
||||||
'Downloaded Program Files',
|
|
||||||
'Internet Cache Files',
|
|
||||||
'Memory Dump Files',
|
|
||||||
'Old ChkDsk Files',
|
|
||||||
'Previous Installations',
|
|
||||||
'Recycle Bin',
|
|
||||||
'Service Pack Cleanup',
|
|
||||||
'Setup Log Files',
|
|
||||||
'System error memory dump files',
|
|
||||||
'System error minidump files',
|
|
||||||
'Temporary Files',
|
|
||||||
'Temporary Setup Files',
|
|
||||||
'Thumbnail Cache',
|
|
||||||
'Update Cleanup',
|
|
||||||
'Upgrade Discarded Files',
|
|
||||||
'Windows Defender',
|
|
||||||
'Windows Error Reporting Archive Files',
|
|
||||||
'Windows Error Reporting Files',
|
|
||||||
'Windows Error Reporting Queue Files',
|
|
||||||
'Windows Error Reporting Temp Files',
|
|
||||||
'Windows ESD installation files',
|
|
||||||
'Windows Upgrade Log Files'
|
|
||||||
)
|
|
||||||
foreach ($cat in $cleanCategories) {
|
|
||||||
$keyPath = "$cleanKey\$cat"
|
|
||||||
if (Test-Path $keyPath) {
|
|
||||||
Set-ItemProperty -Path $keyPath -Name "StateFlags$('{0:D4}' -f $sageset)" -Value 2 -Type DWord -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Host "Running cleanmgr /sagerun:$sageset (may take a few minutes)..."
|
|
||||||
$proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" -Wait -PassThru
|
|
||||||
Write-Host "cleanmgr exited (code $($proc.ExitCode))."
|
|
||||||
|
|
||||||
# 2. Clear Windows Update cache
|
|
||||||
Write-Host "Stopping Windows Update service..."
|
|
||||||
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item 'C:\Windows\SoftwareDistribution\Download\*' -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
|
|
||||||
Write-Host "Windows Update download cache cleared."
|
|
||||||
|
|
||||||
# 3. Clear CBS / component store logs
|
|
||||||
Remove-Item 'C:\Windows\Logs\CBS\*' -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
# 4. Clear TEMP folders
|
|
||||||
Remove-Item 'C:\Windows\Temp\*' -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
# 5. Clear Prefetch
|
|
||||||
Remove-Item 'C:\Windows\Prefetch\*' -Force -ErrorAction SilentlyContinue
|
|
||||||
|
|
||||||
# 6. Run DISM component store cleanup (removes superseded update components)
|
|
||||||
Write-Host "Running DISM component store cleanup (StartComponentCleanup)..."
|
|
||||||
$dism = Start-Process -FilePath 'dism.exe' `
|
|
||||||
-ArgumentList '/Online','/Cleanup-Image','/StartComponentCleanup','/ResetBase' `
|
|
||||||
-Wait -PassThru
|
|
||||||
Write-Host "DISM exited (code $($dism.ExitCode))."
|
|
||||||
|
|
||||||
Write-Host "Disk cleanup complete."
|
|
||||||
|
|
||||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "============================================================" -ForegroundColor Green
|
|
||||||
Write-Host " Template VM setup complete!" -ForegroundColor Green
|
|
||||||
Write-Host "============================================================" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " MANDATORY NEXT STEPS:"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 1. SWITCH NIC TO HOST-ONLY (critical before snapshot!):"
|
|
||||||
Write-Host " VMware Workstation -> VM -> Settings -> Network Adapter"
|
|
||||||
Write-Host " Change from: VMnet8 (NAT)"
|
|
||||||
Write-Host " Change to: Custom: VMnet11"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 2. Shut down this VM: Start -> Shut down"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 3. Take snapshot in VMware Workstation:"
|
|
||||||
Write-Host " VM -> Snapshot -> Take Snapshot"
|
|
||||||
Write-Host " Name it EXACTLY: BaseClean"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 4. Leave VM powered off permanently."
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host " 5. On the HOST - store credentials in Windows Credential Manager:"
|
|
||||||
Write-Host " New-StoredCredential -Target 'BuildVMGuest' -UserName '$BuildUsername' ``"
|
|
||||||
Write-Host " -Password '$BuildPassword' -Persist LocalMachine"
|
|
||||||
Write-Host " (Run in an elevated PowerShell with CredentialManager module)"
|
|
||||||
Write-Host ""
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Windows Server 2025 unattended install template.
|
||||||
|
Double-brace placeholders are substituted by Deploy-WinBuild2025.ps1.
|
||||||
|
|
||||||
|
Boot NIC is e1000e (broad compat). VMware Tools install brings
|
||||||
|
vmxnet3 driver; the host script switches the VMX to vmxnet3
|
||||||
|
after Tools install completes.
|
||||||
|
|
||||||
|
Disk layout (UEFI/GPT, 80 GB):
|
||||||
|
Part 1: EFI 100 MB FAT32 S:
|
||||||
|
Part 2: MSR 16 MB (no fmt)
|
||||||
|
Part 3: Primary rest NTFS C:
|
||||||
|
No Recovery partition (per user choice).
|
||||||
|
|
||||||
|
Image index 2 = "Windows Server 2025 SERVERSTANDARD" (Desktop Experience)
|
||||||
|
on the VOL ISO. Verify with:
|
||||||
|
dism /Get-WimInfo /WimFile:D:\sources\install.wim
|
||||||
|
-->
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend"
|
||||||
|
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
|
||||||
|
<settings pass="windowsPE">
|
||||||
|
<component name="Microsoft-Windows-International-Core-WinPE"
|
||||||
|
processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35"
|
||||||
|
language="neutral"
|
||||||
|
versionScope="nonSxS">
|
||||||
|
<SetupUILanguage>
|
||||||
|
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||||
|
</SetupUILanguage>
|
||||||
|
<InputLocale>{{KEYBOARD}}</InputLocale>
|
||||||
|
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
|
||||||
|
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||||
|
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
|
||||||
|
<UserLocale>{{LOCALE_USER}}</UserLocale>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<component name="Microsoft-Windows-Setup"
|
||||||
|
processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35"
|
||||||
|
language="neutral"
|
||||||
|
versionScope="nonSxS">
|
||||||
|
<DiskConfiguration>
|
||||||
|
<Disk wcm:action="add">
|
||||||
|
<DiskID>0</DiskID>
|
||||||
|
<WillWipeDisk>true</WillWipeDisk>
|
||||||
|
<CreatePartitions>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<Type>EFI</Type>
|
||||||
|
<Size>100</Size>
|
||||||
|
</CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<Type>MSR</Type>
|
||||||
|
<Size>16</Size>
|
||||||
|
</CreatePartition>
|
||||||
|
<CreatePartition wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<Type>Primary</Type>
|
||||||
|
<Extend>true</Extend>
|
||||||
|
</CreatePartition>
|
||||||
|
</CreatePartitions>
|
||||||
|
<ModifyPartitions>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<PartitionID>1</PartitionID>
|
||||||
|
<Format>FAT32</Format>
|
||||||
|
<Label>System</Label>
|
||||||
|
<Letter>S</Letter>
|
||||||
|
</ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>2</Order>
|
||||||
|
<PartitionID>2</PartitionID>
|
||||||
|
</ModifyPartition>
|
||||||
|
<ModifyPartition wcm:action="add">
|
||||||
|
<Order>3</Order>
|
||||||
|
<PartitionID>3</PartitionID>
|
||||||
|
<Format>NTFS</Format>
|
||||||
|
<Label>Windows</Label>
|
||||||
|
<Letter>C</Letter>
|
||||||
|
</ModifyPartition>
|
||||||
|
</ModifyPartitions>
|
||||||
|
</Disk>
|
||||||
|
</DiskConfiguration>
|
||||||
|
|
||||||
|
<ImageInstall>
|
||||||
|
<OSImage>
|
||||||
|
<InstallFrom>
|
||||||
|
<MetaData wcm:action="add">
|
||||||
|
<Key>/IMAGE/INDEX</Key>
|
||||||
|
<Value>{{IMAGE_INDEX}}</Value>
|
||||||
|
</MetaData>
|
||||||
|
</InstallFrom>
|
||||||
|
<InstallTo>
|
||||||
|
<DiskID>0</DiskID>
|
||||||
|
<PartitionID>3</PartitionID>
|
||||||
|
</InstallTo>
|
||||||
|
</OSImage>
|
||||||
|
</ImageInstall>
|
||||||
|
|
||||||
|
<UserData>
|
||||||
|
<AcceptEula>true</AcceptEula>
|
||||||
|
<FullName>CI</FullName>
|
||||||
|
<Organization>CI Lab</Organization>
|
||||||
|
<ProductKey>
|
||||||
|
<Key>{{PRODUCT_KEY}}</Key>
|
||||||
|
<WillShowUI>OnError</WillShowUI>
|
||||||
|
</ProductKey>
|
||||||
|
</UserData>
|
||||||
|
|
||||||
|
<UseConfigurationSet>false</UseConfigurationSet>
|
||||||
|
|
||||||
|
<DynamicUpdate>
|
||||||
|
<Enable>false</Enable>
|
||||||
|
<WillShowUI>Never</WillShowUI>
|
||||||
|
</DynamicUpdate>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
|
||||||
|
<settings pass="specialize">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup"
|
||||||
|
processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35"
|
||||||
|
language="neutral"
|
||||||
|
versionScope="nonSxS">
|
||||||
|
<ComputerName>{{COMPUTER_NAME}}</ComputerName>
|
||||||
|
<TimeZone>{{TIMEZONE}}</TimeZone>
|
||||||
|
<RegisteredOwner>CI</RegisteredOwner>
|
||||||
|
<RegisteredOrganization>CI Lab</RegisteredOrganization>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
|
||||||
|
<settings pass="oobeSystem">
|
||||||
|
<component name="Microsoft-Windows-International-Core"
|
||||||
|
processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35"
|
||||||
|
language="neutral"
|
||||||
|
versionScope="nonSxS">
|
||||||
|
<InputLocale>{{KEYBOARD}}</InputLocale>
|
||||||
|
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
|
||||||
|
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||||
|
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
|
||||||
|
<UserLocale>{{LOCALE_USER}}</UserLocale>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup"
|
||||||
|
processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35"
|
||||||
|
language="neutral"
|
||||||
|
versionScope="nonSxS">
|
||||||
|
<UserAccounts>
|
||||||
|
<AdministratorPassword>
|
||||||
|
<Value>{{ADMIN_PWD}}</Value>
|
||||||
|
<PlainText>true</PlainText>
|
||||||
|
</AdministratorPassword>
|
||||||
|
</UserAccounts>
|
||||||
|
|
||||||
|
<AutoLogon>
|
||||||
|
<Username>{{ADMIN_USER}}</Username>
|
||||||
|
<Password>
|
||||||
|
<Value>{{ADMIN_PWD}}</Value>
|
||||||
|
<PlainText>true</PlainText>
|
||||||
|
</Password>
|
||||||
|
<Enabled>true</Enabled>
|
||||||
|
<LogonCount>5</LogonCount>
|
||||||
|
</AutoLogon>
|
||||||
|
|
||||||
|
<OOBE>
|
||||||
|
<HideEULAPage>true</HideEULAPage>
|
||||||
|
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
|
||||||
|
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
||||||
|
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||||
|
<NetworkLocation>Work</NetworkLocation>
|
||||||
|
<ProtectYourPC>3</ProtectYourPC>
|
||||||
|
<SkipMachineOOBE>true</SkipMachineOOBE>
|
||||||
|
<SkipUserOOBE>true</SkipUserOOBE>
|
||||||
|
</OOBE>
|
||||||
|
|
||||||
|
<TimeZone>{{TIMEZONE}}</TimeZone>
|
||||||
|
<RegisteredOwner>CI</RegisteredOwner>
|
||||||
|
<RegisteredOrganization>CI Lab</RegisteredOrganization>
|
||||||
|
|
||||||
|
<FirstLogonCommands>
|
||||||
|
<SynchronousCommand wcm:action="add">
|
||||||
|
<Order>1</Order>
|
||||||
|
<CommandLine>cmd /c for %d in (C D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist %d:\post-install.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\post-install.ps1 ^> C:\Windows\Temp\post-install.log 2^>^&1</CommandLine>
|
||||||
|
<Description>Run post-install script from autounattend CD</Description>
|
||||||
|
</SynchronousCommand>
|
||||||
|
</FirstLogonCommands>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
</unattend>
|
||||||
Reference in New Issue
Block a user