Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f948f23f0f | |||
| 039fcdb9c9 | |||
| 1c39701534 | |||
| db1530cf0f | |||
| 54399a2120 | |||
| 1c8fdcf028 | |||
| 383d6864ce | |||
| 9fcc46afd6 | |||
| fee5963a65 | |||
| 08f7bb1757 | |||
| 59d38bee51 | |||
| be80293af6 | |||
| 4b2172f09e | |||
| f64880129a | |||
| 90a4396443 | |||
| a60f7a4b90 | |||
| fa2acc4b46 | |||
| a3a24199b3 | |||
| 6b2a23b8f9 | |||
| 8b3823838d | |||
| d1f267b67c | |||
| 73bb9b182b | |||
| 73fca1c74a | |||
| eb6fdfb2ca | |||
| c10c79d4f4 | |||
| e2f1f8b1ea | |||
| 0fd931d6a9 | |||
| c9a9d57b9c | |||
| a981be62f4 | |||
| 3283109bed | |||
| c6b0957865 | |||
| be0a5dd065 | |||
| d732e182ce | |||
| 13ecd823dc | |||
| 021ce9718f | |||
| edfd6a3907 | |||
| 844142b250 | |||
| f9ef9f9769 | |||
| b69c4a2f19 | |||
| 971d3dc6e9 | |||
| 914072fdd8 | |||
| a5719f2ac6 | |||
| 7e6d21678d | |||
| 77d84242b9 | |||
| 173a50a83f | |||
| fac068458d | |||
| d6aabe7138 | |||
| 496d43a9eb | |||
| f05d48435a | |||
| fef8ee6ec5 | |||
| a0c94cb3d3 | |||
| f46e4a8ca3 | |||
| 552eb628b7 | |||
| 1962c977ba | |||
| bc894a8058 | |||
| d4e619ddc2 | |||
| 8b54ca01b3 | |||
| d63611f78e | |||
| ee7f406f2a | |||
| edce871644 | |||
| 34b33c5011 | |||
| 68cde01c9d | |||
| 41611d46a7 | |||
| 7d6ae42fcf | |||
| 57e4a9713e | |||
| ad39aa5cf4 | |||
| 260e275b89 | |||
| 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
|
||||
*.tmp
|
||||
~$*
|
||||
|
||||
# Copilot Orchestrator temporary files
|
||||
.orchestrator/
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# AGENTS.md — Environment Reference
|
||||
|
||||
Questo file descrive l'ambiente su cui e' costruito il progetto.
|
||||
Leggerlo prima di generare o modificare qualsiasi script.
|
||||
|
||||
> **Istruzione per gli agenti**: questo file va tenuto aggiornato.
|
||||
> Se durante il lavoro si scopre un errore nuovo, un vincolo non documentato,
|
||||
> un pattern che ha causato problemi o una best-practice utile, aggiungerla
|
||||
> nella sezione "Errori frequenti da evitare" o in una sezione apposita.
|
||||
> Non aggiungere dettagli ovvi o gia' coperti; solo informazioni che
|
||||
> impedirebbero errori concreti in sessioni future.
|
||||
|
||||
---
|
||||
|
||||
## Host
|
||||
|
||||
| Parametro | Valore |
|
||||
| ------------------ | ----------------------------------------------------------------------- |
|
||||
| OS | Windows 11 |
|
||||
| Hardware | i9-10900X, 64 GB RAM, NVMe SSD |
|
||||
| PowerShell | **5.1** (Windows PowerShell — NON Core/7) |
|
||||
| VMware Workstation | Pro, `vmrun.exe` in `C:\Program Files (x86)\VMware\VMware Workstation\` |
|
||||
| SSH client | `ssh.exe` / `scp.exe` built-in Windows 11 (PATH) |
|
||||
| act_runner | v1.0.2 — `F:\CI\act_runner\act_runner.exe` |
|
||||
| Gitea | `http://10.10.20.11:3100` / `https://gitea.emulab.it` |
|
||||
|
||||
---
|
||||
|
||||
## PowerShell 5.1 — Vincoli critici
|
||||
|
||||
Tutti gli script devono girare su **Windows PowerShell 5.1**. Le seguenti
|
||||
sintassi sono disponibili solo in PS 7+ e NON vanno usate:
|
||||
|
||||
| Costrutto PS 7+ | Alternativa PS 5.1 corretta |
|
||||
| ------------------------- | ----------------------------------------- |
|
||||
| `$x ??= 'default'` | `if ($null -eq $x) { $x = 'default' }` |
|
||||
| `$a ?? $b` | `if ($null -ne $a) { $a } else { $b }` |
|
||||
| `cmd1 && cmd2` | `cmd1; if ($LASTEXITCODE -eq 0) { cmd2 }` |
|
||||
| `cmd1 \|\| cmd2` | `cmd1; if ($LASTEXITCODE -ne 0) { cmd2 }` |
|
||||
| `$x is [int]` | `$x -is [int]` |
|
||||
| Ternary `$a ? $b : $c` | `if ($a) { $b } else { $c }` |
|
||||
| `foreach-object` con `&&` | pipeline classico con `-ErrorAction Stop` |
|
||||
|
||||
Ogni script deve avere in testa:
|
||||
|
||||
```powershell
|
||||
#Requires -Version 5.1
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Struttura directory host (fissa)
|
||||
|
||||
```
|
||||
F:\CI\
|
||||
act_runner\ — binario e stato act_runner
|
||||
Templates\
|
||||
WinBuild2025\ — template Windows Server 2025
|
||||
WinBuild2022\ — template Windows Server 2022
|
||||
LinuxBuild2404\ — template Ubuntu 24.04
|
||||
BuildVMs\ — clone efimeri (creati/distrutti per ogni job)
|
||||
Artifacts\ — artifact raccolti (per job-id)
|
||||
ISO\ — ISO/VMDK di bootstrap (cache download)
|
||||
keys\
|
||||
ci_linux — chiave SSH privata per guest Linux (no passphrase)
|
||||
ci_linux.pub — chiave pubblica corrispondente
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VM Template
|
||||
|
||||
### Windows (WinBuild2025 — primario)
|
||||
|
||||
| Parametro | Valore |
|
||||
| -------------- | ------------------------------------------------------------------------------- |
|
||||
| OS | Windows Server 2025 |
|
||||
| VMX | `F:\CI\Templates\WinBuild2025\WinBuild2025.vmx` |
|
||||
| Snapshot clone | `BaseClean` |
|
||||
| Transport | WinRM HTTPS porta 5986, cert self-signed (lab-only) |
|
||||
| Credenziali | Windows Credential Manager target `BuildVMGuest` |
|
||||
| Rete | VMnet8 NAT — `192.168.79.0/24` |
|
||||
| Toolchain | VS Build Tools 2026 (MSBuild 18.5 / v145), .NET SDK 10, Python 3.13, Git, 7-Zip |
|
||||
|
||||
Variante **WinBuild2022**: path `F:\CI\Templates\WinBuild2022\WinBuild2022.vmx`, stesso snapshot `BaseClean`.
|
||||
|
||||
### Linux (LinuxBuild2404)
|
||||
|
||||
| Parametro | Valore |
|
||||
| -------------- | ------------------------------------------------------------- |
|
||||
| OS | Ubuntu 24.04 LTS (cloud image) |
|
||||
| VMX | `F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx` |
|
||||
| Snapshot clone | `BaseClean-Linux` |
|
||||
| Transport | SSH porta 22, utente `ci_build`, chiave `F:\CI\keys\ci_linux` |
|
||||
| Rete | VMnet8 NAT — `192.168.79.0/24` |
|
||||
| Toolchain | GCC, G++, Clang, CMake, Ninja, Python 3, Git, 7-Zip |
|
||||
| CI dirs | `/opt/ci/{build,output,scripts,cache}` — owner `ci_build` |
|
||||
|
||||
---
|
||||
|
||||
## Transport layer
|
||||
|
||||
- **Windows guest**: WinRM (`New-PSSession` con `New-CISessionOption` da `_Common.psm1`).
|
||||
Usare `Invoke-Command -Session`, `Copy-Item -ToSession / -FromSession`.
|
||||
- **Linux guest**: SSH/SCP via `ssh.exe` / `scp.exe`.
|
||||
Usare le funzioni di `scripts/_Transport.psm1`:
|
||||
`Invoke-SshCommand`, `Copy-SshItem`, `Test-SshReady`.
|
||||
- **vmrun.exe**: sempre invocato tramite `Invoke-Vmrun` da `_Common.psm1`
|
||||
(wrapper uniforme che gestisce `$LASTEXITCODE` e output).
|
||||
|
||||
NON modificare il branch WinRM esistente quando si lavora sul branch Linux e viceversa.
|
||||
|
||||
---
|
||||
|
||||
## act_runner / Gitea Actions
|
||||
|
||||
- Labels runner: `windows-build:host`, `linux-build:host`
|
||||
- Env vars iniettate in ogni job (da `runner/config.yaml`):
|
||||
- `GITEA_CI_TEMPLATE_PATH` — VMX Windows template
|
||||
- `GITEA_CI_LINUX_TEMPLATE_PATH` — VMX Linux template
|
||||
- `GITEA_CI_CLONE_BASE_DIR` — `F:\CI\BuildVMs`
|
||||
- `GITEA_CI_ARTIFACT_DIR` — `F:\CI\Artifacts`
|
||||
- `GITEA_CI_GUEST_CRED_TARGET` — `BuildVMGuest`
|
||||
- `GITEA_CI_SSH_KEY_PATH` — `F:\CI\keys\ci_linux`
|
||||
|
||||
---
|
||||
|
||||
## Regole di stile (PSScriptAnalyzer)
|
||||
|
||||
Configurazione in `PSScriptAnalyzerSettings.psd1`. Regole principali:
|
||||
|
||||
- Indentazione: **4 spazi** (no tab)
|
||||
- `Write-Host` e' permesso (output catturato da act_runner; `Write-Output` interferisce con i return value)
|
||||
- `Invoke-Expression` vietato
|
||||
- Funzioni state-changing devono supportare `-WhatIf` / `ShouldProcess`
|
||||
- Credenziali solo come `[PSCredential]`, mai plain-text string
|
||||
- Nessuna variabile globale (`$global:`)
|
||||
|
||||
---
|
||||
|
||||
## Errori frequenti da evitare
|
||||
|
||||
1. **Sintassi PS 7+** in script PS 5.1 — vedi tabella sopra.
|
||||
2. **`$LASTEXITCODE` non controllato** dopo `vmrun`, `ssh`, `scp` — controllare sempre e fare `throw` se non zero.
|
||||
3. **Percorsi hardcoded senza parametro** — ogni path critico deve avere un parametro con default documentato.
|
||||
4. **Modificare entrambi i branch (WinRM e SSH) nello stesso script** — mantenerli separati con `if ($GuestOS -eq 'Linux')`.
|
||||
5. **Usare `New-PSSession` / WinRM verso host Linux** — Linux usa solo SSH tramite `_Transport.psm1`.
|
||||
6. **Non rimuovere il seed ISO dal VMX** dopo il primo boot Linux — `ide1:0.present = "FALSE"` deve essere settato dopo cloud-init.
|
||||
7. **`[ordered]@{}` non disponibile in PS 2** — non e' un problema qui (PS 5.1), ma ricordare che `[ordered]` e' PS 3+.
|
||||
8. **`ForEach-Object -Parallel`** — disponibile solo in PS 7+; usare `foreach` classico o `Start-Job`.
|
||||
9. **Snapshot template catturato con VM accesa** — produce file `*.vmem` / `*.vmsn` con memoria; `vmrun clone ... linked -snapshot=<name>` rifiuta con `Error: The virtual machine should not be powered on. It is already running.` anche se `vmrun list` mostra 0 VM. Catturare snapshot SOLO da stato fully powered-off (verificare assenza di `.vmem` nella dir template).
|
||||
10. **`vmrun getGuestIPAddress` per check "VM running"** — richiede VMware Tools attivi nel guest; in headless Tools puo' impiegare 30-60s a rispondere e il check appare bloccato. Usare `vmrun list` e cercare il path del VMX nell'output.
|
||||
@@ -0,0 +1,68 @@
|
||||
@{
|
||||
# ── Rules to suppress project-wide ──────────────────────────────────────────
|
||||
ExcludeRules = @(
|
||||
# All CI scripts use Write-Host for structured output captured by act_runner.
|
||||
# Write-Output would interleave with function return values and break callers.
|
||||
'PSAvoidUsingWriteHost'
|
||||
)
|
||||
|
||||
# ── Rule-specific configuration ──────────────────────────────────────────────
|
||||
Rules = @{
|
||||
# Security: never eval arbitrary strings
|
||||
PSAvoidUsingInvokeExpression = @{
|
||||
Enable = $true
|
||||
}
|
||||
|
||||
# All state-changing functions must support -WhatIf / ShouldProcess
|
||||
PSUseShouldProcessForStateChangingFunctions = @{
|
||||
Enable = $true
|
||||
}
|
||||
|
||||
# Credentials must flow as [PSCredential], not plain strings
|
||||
PSUsePSCredentialType = @{
|
||||
Enable = $true
|
||||
}
|
||||
|
||||
# No plain-text passwords in params or variables
|
||||
PSAvoidUsingPlainTextForPassword = @{
|
||||
Enable = $true
|
||||
}
|
||||
|
||||
# ConvertTo-SecureString -AsPlainText only acceptable during template setup;
|
||||
# use Suppress comments there if needed.
|
||||
PSAvoidUsingConvertToSecureStringWithPlainText = @{
|
||||
Enable = $true
|
||||
}
|
||||
|
||||
# No script-scope globals that bleed across module boundaries
|
||||
PSAvoidGlobalVars = @{
|
||||
Enable = $true
|
||||
}
|
||||
|
||||
# Formatting — 4-space indentation, spaces (not tabs)
|
||||
PSUseConsistentIndentation = @{
|
||||
Enable = $true
|
||||
IndentationSize = 4
|
||||
PipelineIndentation = 'IncreaseIndentationForFirstPipeline'
|
||||
Kind = 'space'
|
||||
}
|
||||
|
||||
PSUseConsistentWhitespace = @{
|
||||
Enable = $true
|
||||
CheckInnerBrace = $true
|
||||
CheckOpenBrace = $true
|
||||
CheckOpenParen = $true
|
||||
CheckOperator = $true
|
||||
CheckPipe = $true
|
||||
CheckPipeForRedundantWhitespace = $true
|
||||
CheckSeparator = $true
|
||||
CheckParameter = $false
|
||||
}
|
||||
|
||||
# Alignment is a style preference; disable to avoid false positives on
|
||||
# the deliberate column-aligned hashtable assignments used in this project.
|
||||
PSAlignAssignmentStatement = @{
|
||||
Enable = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Sistema CI/CD self-hosted su Windows per build isolati in VM efimere VMware.
|
||||
|
||||
**Stato**: production-ready — e2e verificato (e2e-009 SUCCESS)
|
||||
**Stato**: production-ready — e2e verificato (e2e-009 SUCCESS); §3.3 in-VM git clone attivabile via `-UseGitClone` (e2e PASS, -25.7% vs baseline)
|
||||
|
||||
---
|
||||
|
||||
@@ -49,12 +49,14 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
|
||||
| Componente | Versione |
|
||||
|---|---|
|
||||
| OS | Windows Server 2025 |
|
||||
| VMX | `F:\CI\Templates\WinBuild\CI-WinBuild.vmx` |
|
||||
| VMX | `F:\CI\Templates\WinBuild2025\WinBuild2025.vmx` (path in `runner/config.yaml` → `GITEA_CI_TEMPLATE_PATH`) |
|
||||
| VS Build Tools | 2026 (MSBuild 18.5.4 / toolset v145) |
|
||||
| .NET SDK | 10.0.203 |
|
||||
| Python | 3.13.3 |
|
||||
| Rete | VMnet8 NAT — `192.168.79.0/24` |
|
||||
| WinRM | HTTP/5985, Basic auth (lab-only) |
|
||||
| WinRM | HTTPS/5986, Basic auth, self-signed cert (SkipCACheck — lab-only) |
|
||||
|
||||
Variante **Windows Server 2022**: usare i corrispettivi `*WinBuild2022.ps1` e VMX `F:\CI\Templates\WinBuild2022\WinBuild2022.vmx`.
|
||||
|
||||
---
|
||||
|
||||
@@ -62,36 +64,62 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
|
||||
|
||||
```
|
||||
├── Setup-Host.ps1 # Bootstrap host: dirs F:\CI\, credenziali, act_runner service
|
||||
├── PSScriptAnalyzerSettings.psd1 # Regole linting condivise (§5.4)
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── Invoke-CIJob.ps1 # Orchestratore principale
|
||||
│ ├── _Common.psm1 # Shared module (vmrun wrapper, session opts) §5.2
|
||||
│ ├── Invoke-CIJob.ps1 # Orchestratore principale (-UseGitClone opt-in §3.3)
|
||||
│ ├── New-BuildVM.ps1 # Linked clone da template
|
||||
│ ├── Wait-VMReady.ps1 # Polling WinRM readiness
|
||||
│ ├── Invoke-RemoteBuild.ps1 # Zip transfer + esecuzione build in VM
|
||||
│ ├── Wait-VMReady.ps1 # Polling WinRM HTTPS readiness
|
||||
│ ├── Invoke-RemoteBuild.ps1 # Zip transfer (o git clone in VM) + build via WinRM
|
||||
│ ├── Get-BuildArtifacts.ps1 # Copia artifacts dall'host
|
||||
│ └── Remove-BuildVM.ps1 # Stop + rimozione VM clone
|
||||
│ ├── Remove-BuildVM.ps1 # Stop + rimozione VM clone (-WhatIf)
|
||||
│ ├── Cleanup-OrphanedBuildVMs.ps1 # Cleanup VM orfane (§2.2)
|
||||
│ ├── Invoke-RetentionPolicy.ps1 # Retention artifact + log (§2.3)
|
||||
│ ├── Watch-DiskSpace.ps1 # Alert spazio libero F: (§4.3)
|
||||
│ ├── Watch-RunnerHealth.ps1 # Health check runner (§2.7)
|
||||
│ ├── Backup-CITemplate.ps1 # Backup VMDK template (§2.6)
|
||||
│ ├── Set-TemplateSharedFolders.ps1 # Cache NuGet/pip via shared folders (§3.1)
|
||||
│ ├── Measure-CIBenchmark.ps1 # Benchmark fasi pipeline (§3.6)
|
||||
│ └── Register-CIScheduledTasks.ps1 # Task Scheduler bootstrap
|
||||
│
|
||||
├── template/
|
||||
│ ├── Prepare-TemplateSetup.ps1 # Provisioning automatico template VM (HOST-side)
|
||||
│ └── Setup-TemplateVM.ps1 # Installazione toolchain nella VM (GUEST-side)
|
||||
│ ├── autounattend.template.xml # Template XML per installazione Windows unattended
|
||||
│ ├── Deploy-WinBuild2025.ps1 # Deploy VM + install Windows 2025 unattended (HOST)
|
||||
│ ├── Prepare-WinBuild2025.ps1 # Provisioning toolchain 2025 via WinRM (HOST-side)
|
||||
│ ├── Setup-WinBuild2025.ps1 # Toolchain CI 2025 nella VM (GUEST-side)
|
||||
│ ├── Deploy-WinBuild2022.ps1 # Deploy VM + install Windows 2022 unattended (HOST)
|
||||
│ ├── Prepare-WinBuild2022.ps1 # Provisioning toolchain 2022 via WinRM (HOST-side)
|
||||
│ ├── Setup-WinBuild2022.ps1 # Toolchain CI 2022 nella VM (GUEST-side)
|
||||
│ ├── Validate-DeployState.ps1 # Validazione stato post-Deploy (2025 e 2022)
|
||||
│ └── Validate-SetupState.ps1 # Validazione stato post-Setup (pre-snapshot)
|
||||
│
|
||||
├── tests/ # Pester v5 unit tests (§5.1)
|
||||
│
|
||||
├── gitea/
|
||||
│ ├── workflows/
|
||||
│ │ └── build-nsis.yml # Workflow live per nsis-plugin-nsinnounp
|
||||
│ │ ├── build-nsis.yml # Workflow live per nsis-plugin-nsinnounp
|
||||
│ │ └── lint.yml # PSScriptAnalyzer su push/PR (§5.4)
|
||||
│ └── workflow-example.yml # Template generico adattabile
|
||||
│
|
||||
├── runner/
|
||||
│ └── config.yaml # Configurazione act_runner (no secrets)
|
||||
│ ├── config.yaml # Configurazione act_runner (no secrets)
|
||||
│ └── Install-Runner.ps1
|
||||
│
|
||||
├── docs/
|
||||
│ ├── ARCHITECTURE.md # Diagramma sistema e componenti
|
||||
│ ├── CI-FLOW.md # Flusso dettagliato passo-passo
|
||||
│ ├── HOST-SETUP.md # Bootstrap macchina host
|
||||
│ ├── WINDOWS-TEMPLATE-SETUP.md # Provisioning template Windows
|
||||
│ ├── LINUX-TEMPLATE-SETUP.md # Bozza template Linux (§6.1, non implementato)
|
||||
│ ├── OPTIMIZATION.md # Layout disco, snapshot, cache, tuning
|
||||
│ ├── BEST-PRACTICES.md # Sicurezza, WinRM, credenziali
|
||||
│ └── Setup-GiteaSSH.md # Configurazione SSH alias per Gitea
|
||||
│ ├── BEST-PRACTICES.md # Sicurezza, WinRM, credenziali, threat model
|
||||
│ ├── RUNBOOK.md # Triage incident comuni (§4.4)
|
||||
│ ├── TEST-PLAN-v1.3-to-HEAD.md # Test plan completo features post-v1.3
|
||||
│ ├── Setup-GiteaSSH.md # Configurazione SSH alias per Gitea
|
||||
│ └── archived/ # Doc storici / sign-off
|
||||
│
|
||||
├── PLAN.md # Stato implementazione e decisioni tecniche
|
||||
└── TODO.md # Task completati e roadmap futura
|
||||
└── TODO.md # Roadmap, audit trail task completati, backlog
|
||||
```
|
||||
|
||||
---
|
||||
@@ -100,10 +128,28 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
|
||||
|
||||
### 1. Template VM
|
||||
|
||||
**Opzione A — Deploy automatico** (crea VM + installa Windows unattended da ISO):
|
||||
|
||||
```powershell
|
||||
# Dalla directory template/, dopo aver installato Windows Server 2025 nella VM
|
||||
# e annotato il suo IP DHCP su VMnet8:
|
||||
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.x -SkipWindowsUpdate
|
||||
cd N:\Code\Workspace\Local-CI-CD-System\template
|
||||
# Windows Server 2025 (corrente):
|
||||
.\Deploy-WinBuild2025.ps1 -ISOPath F:\CI\ISO\WinSrv2025.iso `
|
||||
-VMPath F:\CI\Templates\WinBuild2025\WinBuild2025.vmx
|
||||
# Windows Server 2022 (alternativa):
|
||||
.\Deploy-WinBuild2022.ps1 -ISOPath F:\CI\ISO\WinSrv2022.iso `
|
||||
-VMPath F:\CI\Templates\WinBuild2022\WinBuild2022.vmx
|
||||
```
|
||||
|
||||
**Opzione B — installazione manuale**: installa Windows nella VM, abilita WinRM
|
||||
(`winrm quickconfig -q` + `Enable-PSRemoting -Force`), annota l'IP VMnet8.
|
||||
|
||||
**Provisioning toolchain** (dopo A o B, dalla directory `template/`):
|
||||
|
||||
```powershell
|
||||
# Windows Server 2025:
|
||||
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.x -StoreCredential
|
||||
# Windows Server 2022:
|
||||
.\Prepare-WinBuild2022.ps1 -VMIPAddress 192.168.79.x -StoreCredential
|
||||
```
|
||||
|
||||
Poi spegni la VM e prendi lo snapshot con nome esatto **`BaseClean`**.
|
||||
@@ -114,7 +160,7 @@ Poi spegni la VM e prendi lo snapshot con nome esatto **`BaseClean`**.
|
||||
# PowerShell elevato — una volta sola sull'host
|
||||
Import-Module CredentialManager
|
||||
New-StoredCredential -Target "BuildVMGuest" -UserName "ci_build" `
|
||||
-Password "CIBuild!ChangeMe2026" -Persist LocalMachine
|
||||
-Password "<your-build-password>" -Persist LocalMachine
|
||||
```
|
||||
|
||||
### 3. act_runner
|
||||
@@ -129,12 +175,14 @@ nssm start act_runner
|
||||
```
|
||||
|
||||
Imposta `GITEA_CI_TEMPLATE_PATH` nelle variabili d'ambiente del servizio:
|
||||
`F:\CI\Templates\WinBuild\CI-WinBuild.vmx`
|
||||
`F:\CI\Templates\WinBuild2025\WinBuild2025.vmx`
|
||||
|
||||
Dettagli completi: [docs/HOST-SETUP.md](docs/HOST-SETUP.md).
|
||||
|
||||
### 4. SSH per Gitea
|
||||
|
||||
Vedi [docs/Setup-GiteaSSH.md](docs/Setup-GiteaSSH.md) per configurare l'alias `gitea-ci`
|
||||
(SSH port 2222, necessario per `git clone ssh://gitea-ci/...` durante i build).
|
||||
(SSH port 2222, usato dai workflow live in `git clone ssh://gitea-ci/...`).
|
||||
|
||||
---
|
||||
|
||||
@@ -146,14 +194,16 @@ Vedi [docs/Setup-GiteaSSH.md](docs/Setup-GiteaSSH.md) per configurare l'alias `g
|
||||
-RepoUrl 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git' `
|
||||
-Branch 'main' `
|
||||
-Commit '' `
|
||||
-TemplatePath 'F:\CI\Templates\WinBuild\CI-WinBuild.vmx' `
|
||||
-TemplatePath 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
|
||||
-Submodules `
|
||||
-BuildCommand 'python build_plugin.py --final --dist-dir dist' `
|
||||
-GuestArtifactSource 'dist' `
|
||||
-GuestCredentialTarget 'BuildVMGuest'
|
||||
```
|
||||
|
||||
Artifacts raccolti in: `F:\CI\Artifacts\test-001\artifacts.zip`
|
||||
Artifacts raccolti in: `F:\CI\Artifacts\test-001\artifacts.zip`.
|
||||
|
||||
Aggiungi `-UseGitClone` per clonare direttamente in VM (skip host-zip-transfer; richiede Git+7-Zip in template — già inclusi nel Tier-1 Toolchain §6.6).
|
||||
|
||||
---
|
||||
|
||||
@@ -173,14 +223,22 @@ e aggiorna `-RepoUrl`, `-BuildCommand` e `-GuestArtifactSource`.
|
||||
|---|---|
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Diagramma componenti e flusso dati |
|
||||
| [docs/CI-FLOW.md](docs/CI-FLOW.md) | Descrizione dettagliata di ogni fase del pipeline |
|
||||
| [docs/OPTIMIZATION.md](docs/OPTIMIZATION.md) | Layout NVMe, snapshot strategy, cache, tuning WinRM |
|
||||
| [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md) | Sicurezza WinRM, gestione credenziali, isolamento |
|
||||
| [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) |
|
||||
| [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md) | Bozza template Linux (§6.1, non implementato) |
|
||||
| [docs/OPTIMIZATION.md](docs/OPTIMIZATION.md) | Layout NVMe, snapshot strategy, cache, tuning |
|
||||
| [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md) | Sicurezza WinRM, threat model, credenziali |
|
||||
| [docs/RUNBOOK.md](docs/RUNBOOK.md) | Triage incident comuni (runner offline, clone fail, ecc.) |
|
||||
| [docs/TEST-PLAN-v1.3-to-HEAD.md](docs/TEST-PLAN-v1.3-to-HEAD.md) | Test plan completo features post-v1.3 |
|
||||
| [docs/Setup-GiteaSSH.md](docs/Setup-GiteaSSH.md) | Configurazione SSH alias `gitea-ci` |
|
||||
| [TODO.md](TODO.md) | Roadmap, audit trail task completati, backlog |
|
||||
|
||||
---
|
||||
|
||||
## Note di sicurezza
|
||||
|
||||
WinRM in modalità HTTP/Basic è **accettabile solo in ambiente lab isolato**.
|
||||
Per ambienti di produzione o condivisi, migrare a HTTPS/5986 con certificato self-signed
|
||||
e rimuovere `AllowUnencrypted=true`. Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md).
|
||||
WinRM usa **HTTPS/5986** con certificato self-signed e Basic auth.
|
||||
`-SkipCACheck`/`-SkipCNCheck` sono accettabili in lab isolato (il cert non è di una CA trusted).
|
||||
Per ambienti condivisi o di produzione, usare un certificato CA valida e rimuovere `-SkipCACheck`.
|
||||
Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md).
|
||||
|
||||
|
||||
+11
-7
@@ -82,7 +82,7 @@ param(
|
||||
[string] $CIRoot = 'F:\CI',
|
||||
[string] $GuestCredentialTarget = 'BuildVMGuest',
|
||||
[string] $GuestUsername = 'ci_build',
|
||||
[string] $GuestPassword = 'CIBuild!ChangeMe2026',
|
||||
[string] $GuestPassword = '',
|
||||
[string] $ActRunnerExe = '',
|
||||
[string] $ActRunnerConfigYaml = '',
|
||||
[string] $GiteaUrl = 'http://10.10.20.11:3100',
|
||||
@@ -167,16 +167,19 @@ if ($existingCred) {
|
||||
Write-OK "Credential '$GuestCredentialTarget' already exists (user: $($existingCred.UserName))"
|
||||
Write-Warn "To update: Remove-StoredCredential -Target '$GuestCredentialTarget', then re-run."
|
||||
} 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 `
|
||||
-Target $GuestCredentialTarget `
|
||||
-UserName $GuestUsername `
|
||||
-Password $GuestPassword `
|
||||
-Persist LocalMachine | Out-Null
|
||||
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 ──────────────────────────────────────────
|
||||
@@ -336,12 +339,12 @@ Write-Host " $CIRoot\ISO\"
|
||||
Write-Host ""
|
||||
Write-Host " 3. Provision the template VM:"
|
||||
Write-Host " a. Create VM in VMware Workstation (4 vCPU, 6 GB RAM, 80 GB thin)"
|
||||
Write-Host " VMX: $CIRoot\Templates\WinBuild\CI-WinBuild.vmx"
|
||||
Write-Host " VMX: $CIRoot\Templates\WinBuild2025\WinBuild2025.vmx"
|
||||
Write-Host " NIC: VMnet8 (NAT) — internet required during provisioning"
|
||||
Write-Host " b. Install Windows Server 2025 + enable WinRM inside VM"
|
||||
Write-Host " c. From this host run:"
|
||||
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 ""
|
||||
Write-Host " 4. Register SSH key with Gitea:"
|
||||
@@ -351,3 +354,4 @@ Write-Host ""
|
||||
Write-Host " 5. Verify act_runner is Online in Gitea:"
|
||||
Write-Host " $GiteaUrl/-/admin/runners"
|
||||
Write-Host ""
|
||||
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
# TODO — Local CI/CD System
|
||||
|
||||
<!-- Last updated: 2026-05-08 — e2e-009 SUCCESS, sistema production-ready -->
|
||||
<!-- Last updated: 2026-05-11 — Post Sprint 10: §6.1 Linux Build VM e2e PASS (nsis7z.dll 2272 KB su Linux = parità Windows); In-VM Git Clone sezione chiusa (§3.3 COMPLETATO) -->
|
||||
|
||||
> 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) — provisioning template Linux Ubuntu 24.04 (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
|
||||
|
||||
---
|
||||
## Summary
|
||||
|
||||
_Last updated: 2026-05-11 (post Sprint 10: §6.1 Linux Build VM e2e PASS — Test-Ns7zipBuild-Linux.ps1 4× PASS, nsis7z.dll 2272 KB su Linux = parità Windows; In-VM Git Clone sezione chiusa)_
|
||||
|
||||
**Stato generale**: §1, §2, §4, §5 chiusi. §3 completo. §6.1 Linux Build VM completato con e2e PASS (nsis7z.dll 2272 KB, parità Windows). §6.6 Tier-1 (Git+7-Zip) nel template. Prossimo P2: §6.6 Tier-2 toolchain.
|
||||
|
||||
| Stato | Area | Done | Open | Note |
|
||||
| ----- | ------------------------------- | ---: | ---: | ------------------------------------------------------------------ |
|
||||
| [x] | Setup & Infrastructure | 8 | 0 | Gitea + runner + dirs + rete tutti operativi |
|
||||
| [x] | Template VM (Fasi A→D) | 4 | 0 | Snapshot `BaseClean` preso, credenziali in Credential Manager |
|
||||
| [x] | Scripts Verification | 3 | 0 | e2e-008/009 SUCCESS, cleanup orfani + retry deleteVM ok |
|
||||
| [x] | Runner Configuration | 2 | 0 | `config.yaml`, capacity 4, `BuildVMGuest` |
|
||||
| [ ] | Gitea Workflow Integration | 4 | 1 | manca solo §P3 generalizzazione workflow |
|
||||
| [x] | §1 Sicurezza & hardening | 3 | 0 | 1.1, 1.4, 1.6 done; 1.2/1.3/1.5/1.7/1.8 deferred (home lab) |
|
||||
| [x] | §2 Affidabilità & resilienza | 7 | 0 | tutto done (2.1-2.7) |
|
||||
| [x] | §3 Performance & throughput | 4 | 0 | 3.1/3.2/3.3/3.6 done; 3.4/3.5 deferred — **§3 CHIUSO** |
|
||||
| [x] | §4 Osservabilità & manutenzione | 3 | 0 | 4.1/4.3/4.4 done (4.2 Prometheus, 4.5 Dashboard rimossi) |
|
||||
| [x] | §5 Qualità codice & test | 5 | 0 | 5.1/5.2/5.3/5.4/5.5 done |
|
||||
| [ ] | §6 Scalabilità & estensioni | 2 | 4 | 6.1 (Linux) done, 6.6 Tier-1 (Git+7-Zip) done; 6.2-6.5 TODO |
|
||||
| [ ] | §7 Refactor Deploy ↔ Setup | 4 | 1 | 7.1/7.2/7.3/7.4 done; 7.5 (rinomina) opzionale |
|
||||
| [x] | In-VM Git Clone (§3.3) | 4 | 0 | COMPLETATO 2026-05-10 — e2e PASS: 34s/25.7% vs baseline |
|
||||
| [x] | Linux Build VM (§6.1) | 5 | 0 | Deploy/Prepare/Setup/Transport + doc + e2e PASS nsis7z.dll 2272 KB |
|
||||
|
||||
**Prossimi passi consigliati** (vedi anche "Suggerimento di sequencing" in fondo):
|
||||
1. **Test Plan** — eseguire completo `docs/TEST-PLAN-v1.3-to-HEAD.md` (25+ Pester tests, validazione features)
|
||||
2. **§6.6 expansion** — Toolchain Tier-2 (PowerShell 7, NSIS, CMake, Node.js, WiX, gh, Sysinternals, vcpkg)
|
||||
3. **Security** — Rifiori se condizioni cambiano; vedi sezione "Deferred (Home Lab)"
|
||||
4. **§3.5** — vCPU/RAM tuning per workload (P2, deferred — home lab optimization)
|
||||
5. **§3.4** — Pre-warm pool di cloni (P3, deferred — profiling-dependent optimization)
|
||||
|
||||
---
|
||||
## Setup & Infrastructure
|
||||
|
||||
- [x] **Gitea Server** — già in esecuzione su `http://10.10.20.11:3100` (e `https://gitea.emulab.it`)
|
||||
@@ -24,7 +69,8 @@
|
||||
- [x] `F:\CI\act_runner\`
|
||||
- [x] `F:\CI\Cache\NuGet\`
|
||||
- [x] `F:\CI\RunnerWork\`
|
||||
- [x] `F:\CI\Templates\WinBuild\`
|
||||
- [x] `F:\CI\Templates\WinBuild2025\`
|
||||
- [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] **CredentialManager** PowerShell module v2.0 installato (Scope: CurrentUser)
|
||||
@@ -38,7 +84,7 @@
|
||||
### Fase A — Crea e installa la VM (NIC: NAT per internet)
|
||||
- [x] Crea nuova VM in VMware Workstation con queste impostazioni:
|
||||
- 4 vCPU, 6144 MB RAM, disco 80 GB thin
|
||||
- VMX path: `F:\CI\Templates\WinBuild\WinBuild.vmx`
|
||||
- VMX path: `F:\CI\Templates\WinBuild2025\WinBuild2025.vmx`
|
||||
- **NIC: VMnet8 (NAT)** ← internet necessario per Windows Update e installer
|
||||
- CD-ROM: `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso`
|
||||
- [x] Installa Windows Server 2025 dall'ISO
|
||||
@@ -49,7 +95,7 @@
|
||||
```powershell
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force
|
||||
Set-Item WSMan:\localhost\Service\AllowUnencrypted $true -Force
|
||||
# AllowUnencrypted left false — Deploy post-install.ps1 configures HTTPS/5986 listener
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
|
||||
```
|
||||
- [x] **Attiva Windows Server 2025** (prima dello snapshot — i linked clone ereditano l'attivazione):
|
||||
@@ -67,25 +113,36 @@
|
||||
- [x] Dall'host, esegui:
|
||||
```powershell
|
||||
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.
|
||||
Lo script esegue validazione automatica ad ogni passaggio (`Assert-Step`):
|
||||
- Pre-flight: IP ottetti 0-255, TCP/5986 raggiungibile, Setup-WinBuild2025.ps1 presente
|
||||
- Host WinRM: AllowUnencrypted, TrustedHosts
|
||||
- Guest prep: `C:\CI` esiste, file copiato, size match
|
||||
- Post-setup remoto (9 check): WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs
|
||||
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-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
|
||||
- [x] VM rimane su **VMnet8 (NAT)** — internet access permanente per build che richiedono pip/nuget/npm
|
||||
- [x] Spegni la VM: Start → Shut down
|
||||
- [x] Prendi snapshot: VM → Snapshot → Take Snapshot
|
||||
**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
|
||||
|
||||
### Fase D — Credenziali e config
|
||||
- [x] Archivia credenziali guest sull'host (PowerShell elevato con CredentialManager):
|
||||
```powershell
|
||||
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 `WinBuild2025.vmx` (2026-05-08)
|
||||
|
||||
## Scripts Verification
|
||||
|
||||
@@ -97,6 +154,8 @@
|
||||
- [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-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
|
||||
|
||||
@@ -111,18 +170,459 @@
|
||||
(workflow `build-nsis.yml` già presente e funzionante per `nsis-plugin-nsinnounp`)
|
||||
- [x] Push commit e verificare job in Gitea Actions UI
|
||||
- [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
|
||||
- [ ] 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)
|
||||
## 1. Sicurezza & hardening
|
||||
|
||||
## In-VM Git Clone (Ottimizzazione)
|
||||
### 1.1 [P0] [x] Migrare WinRM da HTTP/Basic a HTTPS/5986 — COMPLETATO 2026-05-10
|
||||
|
||||
**Azioni**:
|
||||
- [x] Generare cert self-signed nel template *prima* dello snapshot (già in Deploy post-install.ps1).
|
||||
- [x] Sostituire `New-PSSession -ComputerName $ip` con `-UseSSL -Port 5986 -Authentication Basic` in `Invoke-RemoteBuild.ps1`, `Get-BuildArtifacts.ps1`, `Prepare-WinBuild2025.ps1`, `Validate-*.ps1`.
|
||||
- [x] Rimuovere `AllowUnencrypted=true` da Deploy post-install.ps1 e Setup-WinBuild2025.ps1 (assertion → false).
|
||||
- [x] `-SkipCACheck`/`-SkipCNCheck`/`-SkipRevocationCheck` mantenuti nei `New-PSSessionOption` e `New-WSManSessionOption` (cert lab self-signed — documentato inline).
|
||||
- [x] Regola firewall `WinRM-HTTPS` in Deploy ristretta a `RemoteAddress '192.168.79.0/24'`.
|
||||
- [x] `Wait-VMReady.ps1` usa `New-WSManSessionOption` + `Test-WSMan -Port 5986 -UseSSL`.
|
||||
- [x] `Prepare-WinBuild2025.ps1`: preflight TCP/5986, host non imposta più `AllowUnencrypted`; solo `TrustedHosts` viene salvato/ripristinato.
|
||||
- [x] `Validate-DeployState.ps1`, `Validate-SetupState.ps1`: connessione HTTPS/5986, check `AllowUnencrypted=false`.
|
||||
|
||||
### 1.4 [P1] [x] Validazione IP per-ottetto — COMPLETATO 2026-05-10
|
||||
|
||||
Regex per-ottetto sostituita in tutti e 4 i file:
|
||||
`'^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$'`
|
||||
|
||||
Estrazione in `scripts/_Common.psm1` — vedi §5.2 (P2, backlog).
|
||||
|
||||
### 1.6 [P2] [x] Defender + Firewall + UAC tutti disattivati nel template — documentare il modello di minaccia — COMPLETATO 2026-05-10
|
||||
File: [docs/BEST-PRACTICES.md:§2.1 Threat Model](docs/BEST-PRACTICES.md).
|
||||
|
||||
**COMPLETATO**: Sezione "2.1. Threat Model — Disabled Security Features" aggiunta a BEST-PRACTICES.md.
|
||||
Documenta:
|
||||
- **Stato corrente**: Defender, Firewall, UAC disabilitate (costi vs. benefici tabellati).
|
||||
- **Quando è accettabile**: ambiente lab isolato con code trusted.
|
||||
- **Quando rompe**: code non fidate, sharing host, esposizione VMnet8 a LAN.
|
||||
- **Mitigazioni**: come riabilitare Firewall, Defender con esclusioni, UAC con costi specifici.
|
||||
|
||||
Future modifiche di sicurezza vanno documentate nello stesso posto.
|
||||
|
||||
---
|
||||
|
||||
## 2. Affidabilità & resilienza
|
||||
|
||||
### 2.1 [P0] [x] Race su IP allocation — COMPLETATO 2026-05-10
|
||||
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] [x] Cleanup orfani schedulato — COMPLETATO 2026-05-10
|
||||
File: [scripts/Cleanup-OrphanedBuildVMs.ps1](scripts/Cleanup-OrphanedBuildVMs.ps1) — script pronto, scheduling registrato da [scripts/Register-CIScheduledTasks.ps1](scripts/Register-CIScheduledTasks.ps1).
|
||||
|
||||
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] [x] Retention artifact + log automatica — COMPLETATO 2026-05-10
|
||||
File: [scripts/Invoke-RetentionPolicy.ps1](scripts/Invoke-RetentionPolicy.ps1) — script creato; scheduling via [scripts/Register-CIScheduledTasks.ps1](scripts/Register-CIScheduledTasks.ps1).
|
||||
|
||||
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] [x] `Remove-BuildVM.ps1` — supportare `-WhatIf` — COMPLETATO 2026-05-10
|
||||
File: [scripts/Remove-BuildVM.ps1:28](scripts/Remove-BuildVM.ps1).
|
||||
|
||||
`[CmdletBinding(SupportsShouldProcess)]` aggiunto; op distruttive (stop, deleteVM, Remove-Item) gated su `$PSCmdlet.ShouldProcess`.
|
||||
|
||||
### 2.5 [P1] [x] Snapshot versionato `BaseClean_<yyyyMMdd>` — COMPLETATO 2026-05-10
|
||||
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] [x] Backup automatico VMDK template — COMPLETATO 2026-05-10
|
||||
File: [scripts/Backup-CITemplate.ps1](scripts/Backup-CITemplate.ps1).
|
||||
|
||||
Copia atomica `F:\CI\Templates\WinBuild` → `F:\CI\Backups\Template_<yyyyMMdd_HHmmss>\`.
|
||||
Ferma act_runner prima, lo riavvia nel `finally`. Prune automatico (default: mantieni 3).
|
||||
`SupportsShouldProcess` → supporta `-WhatIf`. Esecuzione manuale pre-refresh.
|
||||
|
||||
### 2.7 [P2] [x] Health check del runner + monitoraggio Event Log — COMPLETATO 2026-05-10
|
||||
File: [scripts/Watch-RunnerHealth.ps1](scripts/Watch-RunnerHealth.ps1); task registrato da [scripts/Register-CIScheduledTasks.ps1](scripts/Register-CIScheduledTasks.ps1) come `CI-RunnerHealth` (ogni 15 min).
|
||||
|
||||
Controlla `Get-Service act_runner`. Se non Running: EventId 1002 (Warning) + `Restart-Service`.
|
||||
Cooldown: max 3 restart/h via JSON state file `F:\CI\State\runner-restart-log.json`.
|
||||
Oltre il limite: EventId 1004 (Error) + webhook `:sos:` senza restart. Webhook opzionale
|
||||
Discord/Gitea identico a `Watch-DiskSpace.ps1`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Performance & throughput
|
||||
|
||||
### 3.1 [P1] [x] NuGet/pip cache su shared folder — COMPLETATO 2026-05-10
|
||||
File: [scripts/Set-TemplateSharedFolders.ps1](scripts/Set-TemplateSharedFolders.ps1), [scripts/Invoke-RemoteBuild.ps1](scripts/Invoke-RemoteBuild.ps1).
|
||||
|
||||
`Set-TemplateSharedFolders.ps1`: edita il VMX template aggiungendo due shared folder
|
||||
(`nuget-cache` → `F:\CI\Cache\NuGet`, `pip-cache` → `F:\CI\Cache\pip`), idempotente,
|
||||
`.bak` del VMX originale mantenuto, `SupportsShouldProcess`.
|
||||
|
||||
`Invoke-RemoteBuild.ps1`: aggiunto `-UseSharedCache` switch. Quando attivo, inietta
|
||||
`$env:NUGET_PACKAGES` nel scriptblock dotnet restore/build e `$env:PIP_CACHE_DIR` nel
|
||||
scriptblock custom build command.
|
||||
|
||||
**Attivazione**:
|
||||
1. Fermare template VM.
|
||||
2. `.\Set-TemplateSharedFolders.ps1` (una tantum, pre-snapshot).
|
||||
3. Passare `-UseSharedCache` a `Invoke-RemoteBuild.ps1` nei job dotnet/pip.
|
||||
|
||||
Dopo snapshot refresh: cache si riscalda al primo build — accettabile.
|
||||
|
||||
### 3.2 [P1] [x] Sostituire `Compress-Archive` con 7-Zip — COMPLETATO 2026-05-10
|
||||
File: [scripts/Invoke-RemoteBuild.ps1](scripts/Invoke-RemoteBuild.ps1) (3 rimpiazzi `Compress-Archive` → 7-Zip + fallback).
|
||||
|
||||
**Implementazione**:
|
||||
- Host-side: nuova funzione `Compress-BuildArtifact` (check 7-Zip, fallback Compress-Archive) usata per comprimere source tree
|
||||
- Guest-side (custom build): inline 7-Zip check in scriptblock per artifact compression
|
||||
- Guest-side (dotnet build): inline 7-Zip check in scriptblock per output directory compression
|
||||
- Usa `-mmt=on -mx1` per multi-thread con ratio simile a Compress-Archive
|
||||
|
||||
**Vantaggio**: 10-20 s risparmio per repo >100 MB una volta che 7-Zip installato (§6.6).
|
||||
**Attuale**: Fallback a Compress-Archive finché 7-Zip non nel template — zero overhead.
|
||||
|
||||
### 3.3 [P2] [x] In-VM clone (`-UseGitClone`) — COMPLETATO 2026-05-10
|
||||
File: [scripts/Invoke-CIJob.ps1](scripts/Invoke-CIJob.ps1), [scripts/Invoke-RemoteBuild.ps1](scripts/Invoke-RemoteBuild.ps1).
|
||||
|
||||
**Implementazione**:
|
||||
- Invoke-CIJob.ps1: `-UseGitClone` switch + conditional Phase 1 (skip host clone)
|
||||
- Invoke-RemoteBuild.ps1: Mode 1 (host) vs Mode 2 (guest) + params (CloneUrl, CloneBranch, CloneCommit, CloneSubmodules)
|
||||
- Guest clone: git clone direttamente nel VM, PAT da Credential Manager (§1.5 no-log cleanup)
|
||||
- Prerequisiti satisfatti: Git + 7-Zip in template (§6.6, completato)
|
||||
|
||||
**Beneficio**: Elimina host-zip-transfer overhead (rilevante per repo > 200 MB con submoduli).
|
||||
**Prossimo**: E2E test con nsis-plugin-nsinnounp (-UseGitClone flag) per misurare impatto.
|
||||
|
||||
### 3.5 [P2] [ ] vCPU/RAM tuning per workload — DEFERRED (home lab)
|
||||
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.
|
||||
|
||||
**Deferred**: Non necessario per home lab; priorità bassa fino a che performance non è bottleneck.
|
||||
|
||||
### 3.6 [P2] [x] Benchmark baseline — COMPLETATO 2026-05-10
|
||||
File: [scripts/Measure-CIBenchmark.ps1](scripts/Measure-CIBenchmark.ps1).
|
||||
|
||||
Misura 5 fasi per iterazione: clone, start, IP acquire, WinRM ready, destroy.
|
||||
Output: tabella console + append a `F:\CI\Logs\benchmark.jsonl` (JSONL, una riga per iterazione).
|
||||
Supporta `-Iterations N` per ridurre varianza. Importa `_Common.psm1` (`Invoke-Vmrun`, `Resolve-VmrunPath`).
|
||||
|
||||
**Uso**: `.\Measure-CIBenchmark.ps1` prima e dopo §3.1/§3.2 per misurare impatto.
|
||||
|
||||
---
|
||||
|
||||
## 4. Osservabilità & manutenzione
|
||||
|
||||
### 4.1 [P1] [x] Log strutturati per parsing — COMPLETATO 2026-05-10
|
||||
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.3 [P1] [x] Disk space alert — COMPLETATO 2026-05-10
|
||||
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] [x] Runbook per incident comuni — COMPLETATO 2026-05-10
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Qualità codice & test
|
||||
|
||||
### 5.1 [P1] [x] Pester smoke tests sugli script — COMPLETATO 2026-05-10
|
||||
File: [tests/_Common.Tests.ps1](tests/_Common.Tests.ps1), [tests/New-BuildVM.Tests.ps1](tests/New-BuildVM.Tests.ps1), [tests/Remove-BuildVM.Tests.ps1](tests/Remove-BuildVM.Tests.ps1), [tests/Wait-VMReady.Tests.ps1](tests/Wait-VMReady.Tests.ps1).
|
||||
|
||||
Test con fake vmrun `.cmd` (exit code via `%FAKE_VMRUN_EXIT%` env var — nessuna VM reale):
|
||||
- `_Common.psm1`: `New-CISessionOption` TLS flags, `Resolve-VmrunPath` throw/return, `Invoke-Vmrun` ExitCode/Output/ThrowOnError
|
||||
- `New-BuildVM.ps1`: throw su template mancante, throw + cleanup su clone fail, formato nome Clone_{JobId}_{timestamp}
|
||||
- `Remove-BuildVM.ps1`: no-op su VMX mancante, cleanup dir parziale, -WhatIf non distrugge, destroy rimuove dir
|
||||
- `Wait-VMReady.ps1`: throw su vmrun mancante, throw su timeout, ValidatePattern IP (accetta/rifiuta)
|
||||
|
||||
Eseguire: `Invoke-Pester tests\ -Output Detailed`
|
||||
|
||||
### 5.2 [P2] [x] Modulo PowerShell condiviso `scripts/_Common.psm1` — COMPLETATO 2026-05-10
|
||||
File: [scripts/_Common.psm1](scripts/_Common.psm1).
|
||||
|
||||
Esporta: `New-CISessionOption` (WinRM self-signed TLS), `Resolve-VmrunPath` (valida vmrun.exe),
|
||||
`Invoke-Vmrun` (wrapper uniforme `-T ws`, ritorna `{ExitCode, Output}`, supporta `-ThrowOnError`).
|
||||
|
||||
Importato da: `Invoke-RemoteBuild.ps1`, `Get-BuildArtifacts.ps1`, `New-BuildVM.ps1`.
|
||||
|
||||
### 5.3 [P2] [x] Esecuzione `vmrun` con check exit code uniforme — COMPLETATO 2026-05-10
|
||||
`Invoke-Vmrun` in `_Common.psm1`. `New-BuildVM.ps1` refactored per usarlo.
|
||||
`Invoke-RemoteBuild.ps1` e `Get-BuildArtifacts.ps1` usano `New-CISessionOption`.
|
||||
|
||||
### 5.4 [P2] [x] PSScriptAnalyzer rules custom — COMPLETATO 2026-05-10
|
||||
File: [PSScriptAnalyzerSettings.psd1](PSScriptAnalyzerSettings.psd1).
|
||||
|
||||
Regole attive: `PSAvoidUsingInvokeExpression`, `PSUseShouldProcessForStateChangingFunctions`,
|
||||
`PSUsePSCredentialType`, `PSAvoidUsingPlainTextForPassword`,
|
||||
`PSAvoidUsingConvertToSecureStringWithPlainText`, `PSAvoidGlobalVars`,
|
||||
`PSUseConsistentIndentation` (4 spazi), `PSUseConsistentWhitespace`.
|
||||
Soppresso progetto-wide: `PSAvoidUsingWriteHost` (output strutturato per act_runner).
|
||||
|
||||
### 5.5 [P3] [x] Type hints nei param block — COMPLETATO 2026-05-10
|
||||
File: tutti script in `scripts/` — 15 file, tutti con type hints.
|
||||
|
||||
**Coverage**: Tutti param hanno type hints (`[string]`, `[int]`, `[switch]`) +
|
||||
validation (`[ValidateRange]`, `[ValidatePattern]`, `[ValidateScript]`) +
|
||||
`[Parameter(Mandatory)]` dove necessario.
|
||||
|
||||
---
|
||||
|
||||
## 6. Scalabilità & estensioni
|
||||
|
||||
### 6.1 [P2] [x] 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.
|
||||
|
||||
Implementato — vedi 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).
|
||||
|
||||
**Status**: Git + 7-Zip implemented (Step 11, 2026-05-10); Tier-2 (8 tools) pending.
|
||||
|
||||
**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.
|
||||
|
||||
### Tier-1 (implementato)
|
||||
- [x] **Git for Windows** (v2.54.0.windows.1) — Step 11, self-clone in VM
|
||||
- [x] **7-Zip** (26.01) — Step 11, universal compression + fallback §3.2
|
||||
|
||||
### Tier-2 (backlog) — tool da aggiungere come ulteriori step `Setup-WinBuild2025` (ognuno con `Assert-Step`):
|
||||
|
||||
| Tool | Versione | Razionale |
|
||||
| ------------------- | ----------------------- | ------------------------------------------------------------------ |
|
||||
| **Git for Windows** | 2.54.0.windows.1 | [x] Self-clone in VM (alt a host-zip-transfer), submodule fetch |
|
||||
| **7-Zip** | 26.01 | [x] 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] [x] Spostare base OS hardening da Setup a Deploy
|
||||
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
|
||||
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||
|
||||
- [x] Aggiungere `Set-NetFirewallProfile -Enabled False` (tutti i profili) in Deploy post-install.ps1
|
||||
- [x] Aggiungere `MaxMemoryPerShellMB=2048`, `IdleTimeOut=7200000`, `MaxProcessesPerShell=25`, `StartupType=Automatic` in Deploy dopo Enable-PSRemoting
|
||||
- [x] Aggiungere in Deploy items mancanti rispetto a Setup 5c: lock screen (`NoLockScreen`), SM GPO policy key (`DoNotOpenAtLogon`), SM scheduled task disable, `DisableCAD` in Winlogon, OOBE non-policy key
|
||||
- [x] Setup Step 1 (Firewall) → Assert-Step only
|
||||
- [x] Setup Step 2 (Defender) → già validation-only (refactor 2026-05-09)
|
||||
- [x] Setup Step 3 (WinRM tuning) → Assert-Step only (rimuovi Enable-PSRemoting, winrm set, Set-Item, Set-Service)
|
||||
- [x] Setup Step 4b (UAC) → Assert-Step only
|
||||
- [x] Setup Step 5b (Explorer LaunchTo) → Assert-Step only
|
||||
- [x] Setup Step 5c (Server Manager / DisableCAD / OOBE) → Assert-Step only
|
||||
- [x] E2e validation — completata §7.4 (2026-05-10)
|
||||
|
||||
### 7.2 [P1] [x] WU lifecycle — strategia B (Deploy: GPO + Manual; Setup: lifecycle)
|
||||
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
|
||||
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||
|
||||
Strategia B: Deploy lascia servizi WU `start=demand` (Manual, default Windows) e scrive solo
|
||||
GPO `NoAutoUpdate=1` + `DisableWindowsUpdateAccess=1`. Setup Step 6 clears i GPO locks (Step A),
|
||||
avvia WU via SchTask SYSTEM, poi Step 6b disabilita servizi permanentemente post-update.
|
||||
|
||||
- [x] Deploy: rimuovere `sc.exe config wuauserv start= disabled` (e UsoSvc, WaaSMedicSvc)
|
||||
- [x] Deploy: aggiungere GPO `DisableWindowsUpdateAccess=1` (era mancante; `NoAutoUpdate=1` già presente)
|
||||
- [x] Setup Step 6 Step A: già rimuove `DisableWindowsUpdateAccess` + `NoAutoUpdate` prima del COM API — nessuna modifica necessaria
|
||||
- [x] Setup Step 6 Step D: già fa `Set-Service start=Manual` + `Start-Service` — idempotente con servizi già Manual
|
||||
- [x] Setup Step 6b: già disabilita wuauserv/UsoSvc + GPO keys — nessuna modifica necessaria
|
||||
- [x] E2e validation — completata §7.4 (2026-05-10)
|
||||
|
||||
### 7.3 [P2] [x] Update header docs di entrambi gli script
|
||||
|
||||
- [x] [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1) `.DESCRIPTION`: aggiornato con tutte le hardening voci (firewall, WinRM tuning, WU GPO strategy B, lock screen, SM, OOBE)
|
||||
- [x] [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1) `.DESCRIPTION`: Steps 1/3/4b/5b/5c marcati "validation-only — set by Deploy"; step ordering rationale aggiornato
|
||||
- [x] [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md): architettura "tre script", VMX path `WinBuild2025.vmx`, Fase C step list + razionale + tabella validazioni aggiornati
|
||||
|
||||
### 7.4 [P2] [x] Test e2e refactor — COMPLETATO 2026-05-10
|
||||
|
||||
- [x] Deploy stand-alone su VM test → `Get-NetFirewallProfile` tutti `Enabled=False`, `Get-Service wuauserv | Select StartType` → `Manual`, `NoAutoUpdate=1` GPO presente
|
||||
- [x] Prepare dopo Deploy con `-SkipWindowsUpdate` → tutti `Assert-Step` passano `[OK]` senza eseguire Set (log mostra solo validation output)
|
||||
- [x] Prepare senza `-SkipWindowsUpdate` → WU (0 update trovati, ResultCode=0), Step 6b disabilita wuauserv/UsoSvc (`StartType=Disabled`)
|
||||
- [x] Final gate del guest passa su tutti e 7 i check; snapshot `BaseClean` preso
|
||||
|
||||
**Fix emersi durante §7.4** (applicati):
|
||||
- Deploy: `UsoSvc` impostato a `Manual` esplicitamente (default Server 2025 = Automatic)
|
||||
- Setup cleanup: `Set-Service -StartupType Disabled` ri-applicato su wuauserv/UsoSvc dopo DISM `StartComponentCleanup` (DISM/WaaSMedicSvc può resettare StartType)
|
||||
- Deploy + Setup: autologin Administrator (`AutoAdminLogon=1`) aggiunto in post-install.ps1 e validato in Assert-Step 5c
|
||||
- Nuovi script: `template/Validate-DeployState.ps1`, `template/Validate-SetupState.ps1`
|
||||
|
||||
### 7.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
|
||||
nella VM, eliminando il ciclo host-clone → zip → WinRM-transfer → unzip.
|
||||
@@ -141,7 +641,7 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
|
||||
**Punti chiave:**
|
||||
- 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 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`
|
||||
- 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)
|
||||
@@ -149,86 +649,154 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
|
||||
|
||||
### Task
|
||||
|
||||
- [ ] **Setup-TemplateVM.ps1 — installare Git for Windows**
|
||||
- [ ] Scaricare e installare Git for Windows (installer silenzioso da git-scm.com)
|
||||
```powershell
|
||||
$gitUrl = 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/Git-2.47.1-64-bit.exe'
|
||||
Start-Process $gitUrl '/VERYSILENT /NORESTART /COMPONENTS=gitlfs' -Wait
|
||||
```
|
||||
- [ ] Aggiungere `C:\Program Files\Git\cmd` al PATH di sistema nella VM
|
||||
- [ ] Verificare: `git --version` ritorna exit 0 nella VM
|
||||
- [ ] Rimuovere il commento "Git is NOT installed" dal docblock di Setup-TemplateVM.ps1
|
||||
- [x] **Tool prerequisiti nel template** — implementati in §6.6 (Tier-1 Toolchain):
|
||||
- Git for Windows + 7-Zip + gh CLI installati con hash pinning e `Assert-Step`
|
||||
- Rimuovere il commento "Git is NOT installed" dal docblock di Setup-WinBuild2025.ps1
|
||||
una volta applicato §6.6
|
||||
- curl.exe: già presente in Windows Server 2025 — verificare con `where.exe curl`
|
||||
|
||||
- [ ] **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`**
|
||||
- [ ] Quando `-UseGitClone` è attivo:
|
||||
- [x] **Invoke-CIJob.ps1 — aggiungere switch `-UseGitClone`**
|
||||
- [x] Quando `-UseGitClone` è attivo:
|
||||
- Saltare Phase 1 (host git clone) e la creazione del `HostSourceDir` temporaneo
|
||||
- Dopo Wait-VMReady, leggere PAT da Windows Credential Manager sull'host
|
||||
(es. target `git:https://gitea.emulab.it/...` o un target dedicato CI)
|
||||
- Passare PAT come parametro a `Invoke-RemoteBuild.ps1` (mai loggato/stampato)
|
||||
- [ ] Comportamento default invariato (`-UseGitClone` = `$false`) — nessuna regressione
|
||||
- Passare PAT a `Invoke-RemoteBuild.ps1` rispettando i vincoli sicurezza di §1.5
|
||||
- [x] Comportamento default invariato (`-UseGitClone` = `$false`) — nessuna regressione
|
||||
|
||||
- [ ] **Invoke-RemoteBuild.ps1 — modalità in-VM clone**
|
||||
- [ ] Aggiungere parametri: `-CloneUrl`, `-CloneBranch`, `-CloneCommit`, `-GitPat`
|
||||
- [x] **Invoke-RemoteBuild.ps1 — modalità in-VM clone**
|
||||
- [x] Aggiungere parametri: `-CloneUrl`, `-CloneBranch`, `-CloneCommit`, `-GitPat`
|
||||
(tutti opzionali, usati solo quando `-UseGitClone` è attivo)
|
||||
- [ ] Quando presenti: anziché copiare zip, eseguire nella VM:
|
||||
- [x] Quando presenti: anziché copiare zip, eseguire nella VM:
|
||||
```powershell
|
||||
git clone --recurse-submodules `
|
||||
"https://Simone:$env:CI_GIT_PAT@gitea.emulab.it/Simone/<repo>.git" `
|
||||
C:\CI\build
|
||||
git -C C:\CI\build checkout $CloneCommit # pin al commit esatto
|
||||
```
|
||||
- [ ] Iniettare PAT come variabile d'ambiente della sessione WinRM (non argv, non log)
|
||||
- [ ] Pulire la variabile d'ambiente dopo il clone: `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`
|
||||
- [x] Implementare iniezione/pulizia PAT secondo §1.5 (env var sessione, no argv, no log,
|
||||
cleanup `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`)
|
||||
|
||||
- [ ] **Test e2e con `-UseGitClone`**
|
||||
- [ ] Aggiornare snapshot `BaseClean` con Git installato
|
||||
- [ ] Eseguire `e2e-010` con `-UseGitClone` su `nsis-plugin-nsinnounp` (ha submoduli)
|
||||
- [ ] Confrontare tempo totale con e2e-009 (senza `-UseGitClone`)
|
||||
- [ ] Verificare che il PAT non appaia nei log di `Invoke-CIJob.ps1`
|
||||
- [x] **Test e2e con `-UseGitClone`** — COMPLETATO 2026-05-10
|
||||
- [x] Aggiornare snapshot `BaseClean` con Git installato
|
||||
- [x] Eseguire `e2e-010` con `-UseGitClone` su `nsis-plugin-nsinnounp` (ha submoduli)
|
||||
- [x] Confrontare tempo totale con e2e-009 (senza `-UseGitClone`) — 34s / 25.7% più veloce
|
||||
- [x] Verificare che il PAT non appaia nei log di `Invoke-CIJob.ps1`
|
||||
|
||||
---
|
||||
|
||||
## Linux Build VM (Future)
|
||||
## Linux Build VM — riferimento §6.1 [IMPLEMENTATO]
|
||||
|
||||
Aggiungere supporto per build su Linux per testare la compilazione cross-platform
|
||||
(es. versione Linux del plugin o build check con GCC/Clang).
|
||||
Supporto build su Linux aggiunto con le seguenti fasi completate:
|
||||
|
||||
- [ ] **Template VM Linux** — Provisionare template VM con Ubuntu Server 24.04 LTS
|
||||
- [ ] Scegliere distro: Ubuntu 24.04 LTS (raccomandato) o Debian 12
|
||||
- [ ] Installare toolchain: GCC, Clang, CMake, Python 3.x, build-essential
|
||||
- [ ] Configurare accesso SSH (chiave pubblica da host) in alternativa/integrazione a WinRM
|
||||
- [ ] Prendere snapshot `BaseClean-Linux`
|
||||
- [ ] Archiviare credenziali SSH in Credential Manager (o key file su host)
|
||||
- [x] **Template VM Linux** — script di provisioning per Ubuntu Server 24.04 LTS
|
||||
- [x] `template/Deploy-LinuxBuild2404.ps1` — crea VM da cloud VMDK, cloud-init, SSH wait
|
||||
- [x] `template/Setup-LinuxBuild2404.sh` — installa toolchain CI (GCC, Clang, CMake, Python 3, git, 7z), hardening SSH, swap off, timer apt off
|
||||
- [x] `template/Prepare-LinuxBuild2404.ps1` — SCP script, esegue via SSH, valida, snapshot `BaseClean-Linux`
|
||||
|
||||
- [ ] **Invoke-CIJob.ps1 — branch Linux**
|
||||
- [ ] Aggiungere parametro `-GuestOS` (`Windows` / `Linux`) o rilevamento automatico da VMX
|
||||
- [ ] Sostituire WinRM con SSH + SCP per trasferimento zip e raccolta artifacts
|
||||
- [ ] Aggiungere supporto a comandi Linux (`bash`, `make`, `cmake --build`)
|
||||
- [x] **Invoke-CIJob.ps1 — branch Linux**
|
||||
- [x] Parametro `-GuestOS` (Windows/Linux/Auto) con auto-detect dal VMX `guestOS` field
|
||||
- [x] Transport SSH via `scripts/_Transport.psm1` (Invoke-SshCommand, Copy-SshItem, Test-SshReady)
|
||||
- [x] Phase 4/5/6 branch su GuestOS: SSH per Linux, WinRM per Windows (nessuna regressione)
|
||||
|
||||
- [ ] **Workflow Gitea — build matrix**
|
||||
- [ ] Definire matrix strategy `[windows-build, linux-build]` nel workflow
|
||||
- [ ] Aggiungere label `linux-build` al runner (o registrare runner separato se necessario)
|
||||
- [ ] Verificare che artifacts da entrambe le piattaforme vengano raccolti correttamente
|
||||
- [x] **Workflow Gitea — build matrix**
|
||||
- [x] Label `linux-build:host` aggiunto a `runner/config.yaml`
|
||||
- [x] `GITEA_CI_LINUX_TEMPLATE_PATH` e `GITEA_CI_SSH_KEY_PATH` aggiunti alle env vars del runner
|
||||
- [x] Matrix strategy cross-platform (windows + linux) documentata in `gitea/workflow-example.yml`
|
||||
|
||||
- [ ] **Test e2e Linux** — eseguire almeno un build end-to-end su VM Linux
|
||||
- [ ] Confermare che `build_plugin.py` (o analogo script) funzioni su Linux
|
||||
- [ ] Confrontare artifact Linux con quelli Windows
|
||||
- [x] **Doc updates**
|
||||
- [x] `docs/ARCHITECTURE.md` — SSH transport, _Transport.psm1, Linux VM boxes, topology SSH :22
|
||||
- [x] `docs/CI-FLOW.md` — Step 0: Linux template + SSH key; Step 6 SSH transport note; Steps 7-8 Linux branch
|
||||
- [x] `docs/HOST-SETUP.md` — directory layout: keys\ + LinuxBuild2404\; SSH key gen step; Reference Paths Linux
|
||||
- [x] `docs/LINUX-TEMPLATE-SETUP.md` — Status: implementato; tutte le checkbox Fasi A-E marcate [x]
|
||||
|
||||
- [x] **Test e2e Linux** — COMPLETATO 2026-05-11 — Test-Ns7zipBuild-Linux.ps1 4× PASS
|
||||
- [x] Confermare che la build di esempio funzioni su Linux — nsis7z.dll 2272 KB prodotto
|
||||
- [x] Confrontare artifact Linux con quelli Windows — 2272 KB su entrambe le piattaforme
|
||||
|
||||
---
|
||||
|
||||
## Security Hardening (Post-MVP)
|
||||
## Deferred (Home Lab)
|
||||
|
||||
- [ ] Passare WinRM da HTTP (5985) a HTTPS (5986) con certificato self-signed
|
||||
- [ ] Rimuovere `AllowUnencrypted=true` dalla config WinRM dopo migrazione HTTPS
|
||||
- [ ] Verificare che Get-StoredCredential funzioni correttamente negli script
|
||||
- [ ] Regole firewall — limitare WinRM alla subnet build VM (192.168.79.0/24 — VMnet8 NAT)
|
||||
- [ ] Rotazione password guest VM trimestralmente
|
||||
Nel contesto di un home lab isolato, i seguenti security task hanno costi eccessivi
|
||||
relativi ai benefici in questo ambiente. **Rifiori se le condizioni cambiano**
|
||||
(code non fidata, sharing host, esposizione di rete, etc.).
|
||||
|
||||
### 1.2 [P1] [ ] Audit TrustedHosts setting
|
||||
File: [docs/BEST-PRACTICES.md:§2](docs/BEST-PRACTICES.md).
|
||||
|
||||
Verificare che host Windows abbia `WSMan:\localhost\Client\TrustedHosts` configurato
|
||||
per `192.168.79.*` instead of `*`. Attualmente impostato a `*` durante setup template.
|
||||
|
||||
**Stato attuale**: Accettabile per lab — WinRM è isolato su VMnet8 NAT.
|
||||
**Rifiori se**: Host esposto a LAN / internet.
|
||||
|
||||
### 1.3 [P0] [ ] Pinning hash SHA256 degli installer
|
||||
File: [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
|
||||
|
||||
SHA256 hash per dotnet-install.ps1, Python installer, vs_buildtools.exe.
|
||||
|
||||
**Stato attuale**: Struttura aggiunta, valori placeholder — funziona con warning.
|
||||
**Rifiori quando**: Build di codice di terze parti non fidate che scaricano installer.
|
||||
|
||||
### 1.5 [P1] [ ] PAT mai persistito per `-UseGitClone`
|
||||
File: sezione "In-VM Git Clone (Ottimizzazione)" in TODO.
|
||||
|
||||
Vincoli: PAT da Credential Manager subito prima sessione, non in argv/log,
|
||||
cancellato in finally, grep post-job se compare in transcript.
|
||||
|
||||
**Stato attuale**: Struttura documentata, non implementata.
|
||||
**Rifiori quando**: `-UseGitClone` implementato (§3.3).
|
||||
|
||||
### 1.7 [P2] [ ] Rotazione password guest VM
|
||||
Aggiornare `BuildVMGuest` in Credential Manager + nel template ogni trimestre.
|
||||
|
||||
**Stato attuale**: Password manuale.
|
||||
**Rifiori quando**: Change management/compliance lo richiede.
|
||||
|
||||
### 1.8 [P2] [ ] Verifica Get-StoredCredential
|
||||
Confermare che `Get-StoredCredential` (CredentialManager module) funzioni
|
||||
in tutti gli script che lo usano post-migrazione HTTPS.
|
||||
|
||||
**Stato attuale**: Usato in template ma non testato in unit test.
|
||||
**Rifiori quando**: Script fallisce su recovery da Credential Manager.
|
||||
|
||||
### 3.4 [P3] [ ] Pre-warm pool di cloni
|
||||
File: `F:\CI\WarmPool\` (hypothetical); scheduled task via `scripts/Register-CIScheduledTasks.ps1`.
|
||||
|
||||
Mantenere 2 cloni avviati in background, pronti per il binding a job. Salta la fase di
|
||||
`New-BuildVM + Wait-VMReady` (~30-60s) nel percorso critico. Richiede una VM "watcher" che
|
||||
monitora il pool e ricrea cloni dopo uso.
|
||||
|
||||
**Stato attuale**: Disegnato ma non implementato. Dipende da §2.1 (IP allocator).
|
||||
**Rifiori quando**: Profiling mostra che New-BuildVM+Wait-VMReady è bottleneck >30% nel
|
||||
tempo totale di job. Con build >2 minuti, overhead stimato ~40% (rilevante solo per 1-min builds).
|
||||
|
||||
### 3.5 [P2] [ ] vCPU/RAM tuning per workload
|
||||
File: [docs/OPTIMIZATION.md](docs/OPTIMIZATION.md), VMX template config.
|
||||
|
||||
Esporre parametri `-VMCpu` / `-VMMemory` a `Invoke-CIJob.ps1` per permettere override
|
||||
da workflow. Attualmente fisso a `numvcpus=4, memsize=6144` — subottimo per build leggeri
|
||||
(waste) e pesanti (throttle).
|
||||
|
||||
**Stato attuale**: Progettato, non implementato. Template supporta la modifica.
|
||||
**Rifiori quando**: Performance profiling mostra contentione CPU/RAM è bottleneck per la
|
||||
workload target. Per home lab, 4 vCPU/6 GB è ragionevole compromesso.
|
||||
|
||||
---
|
||||
|
||||
## Suggerimento di sequencing
|
||||
|
||||
Sprint progressivi per ridurre rischio / ottenere valore prima:
|
||||
|
||||
1. **Sprint 1 (sicurezza)** — §1.1, §1.2, §1.3, §1.4 — superficie di attacco e bug noti.
|
||||
2. **Sprint 2 (operatività)** — §2.1, §2.2, §2.3, §2.5 — rendere il sistema "fire and forget".
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -240,10 +808,11 @@ Aggiungere supporto per build su Linux per testare la compilazione cross-platfor
|
||||
| act_runner | `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\` |
|
||||
| Template VMX | `F:\CI\Templates\WinBuild\CI-WinBuild.vmx` |
|
||||
| Template VMX | `F:\CI\Templates\WinBuild2025\WinBuild2025.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) |
|
||||
|
||||
+30
-16
@@ -6,8 +6,9 @@ A fully local, isolated CI/CD pipeline running on a Windows host using:
|
||||
- **Gitea** as the self-hosted Git server + CI platform
|
||||
- **act_runner** as the job executor (runs on the Windows host)
|
||||
- **VMware Workstation** for ephemeral build VMs (linked clones)
|
||||
- **WinRM / PowerShell Remoting** for communication with build VMs
|
||||
- **MSBuild / dotnet CLI** executing inside the guest VM only
|
||||
- **WinRM / PowerShell Remoting** for communication with Windows build VMs
|
||||
- **SSH / SCP** (`ssh.exe` + `scp.exe`) for communication with Linux build VMs
|
||||
- **MSBuild / dotnet CLI** (Windows) and **GCC / Clang / CMake** (Linux) executing inside guest VMs only
|
||||
|
||||
The runner host **never executes build tools directly**. Its only role is orchestration: creating, driving, and destroying VMs.
|
||||
|
||||
@@ -38,10 +39,11 @@ The runner host **never executes build tools directly**. Its only role is orches
|
||||
│ │ Orchestrator Scripts (PowerShell) │ │
|
||||
│ │ │ │
|
||||
│ │ New-BuildVM.ps1 ──► vmrun.exe (linked clone from template) │ │
|
||||
│ │ Wait-VMReady.ps1 ─► Test-WSMan poll loop │ │
|
||||
│ │ Invoke-RemoteBuild.ps1 ► PSSession + Invoke-Command │ │
|
||||
│ │ Get-BuildArtifacts.ps1 ► Copy-Item -FromSession │ │
|
||||
│ │ Remove-BuildVM.ps1 ───► vmrun stop + deleteVM │ │
|
||||
│ Wait-VMReady.ps1 ─► WinRM poll (Windows) / SSH poll (Linux) │ │
|
||||
│ Invoke-RemoteBuild.ps1 ► PSSession (Windows) / SSH+SCP (Linux) │ │
|
||||
│ Get-BuildArtifacts.ps1 ► Copy-Item -FromSession / scp (Linux) │ │
|
||||
│ Remove-BuildVM.ps1 ───► vmrun stop + deleteVM │ │
|
||||
│ _Transport.psm1 ──────► Invoke-SshCommand / Copy-SshItem │ │
|
||||
│ └───────────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ vmrun.exe (VMware CLI) │
|
||||
│ ▼ │
|
||||
@@ -53,7 +55,7 @@ The runner host **never executes build tools directly**. Its only role is orches
|
||||
│ │ ├── Clone_Job_002.vmx │ │
|
||||
│ │ └── Clone_Job_003.vmx │ │
|
||||
│ └───────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ WinRM :5985 (192.168.79.0/24 NAT) │
|
||||
│ │ WinRM :5986 HTTPS (192.168.79.0/24 NAT) │
|
||||
└──────────────────────────────────┼──────────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────┼──────────────────────────┐
|
||||
@@ -103,21 +105,29 @@ The runner host **never executes build tools directly**. Its only role is orches
|
||||
- Disk footprint: ~2–5 GB per clone (vs 40–80 GB for full clone)
|
||||
- Tradeoff: template snapshot must remain intact
|
||||
|
||||
### WinRM / PowerShell Remoting
|
||||
- Default transport for remote build execution inside VMs
|
||||
- Port: **5985** (HTTP, in-lab use); migrate to 5986/HTTPS for hardened setups
|
||||
- Authentication: **Basic** (unencrypted — acceptable for isolated lab)
|
||||
### WinRM / PowerShell Remoting (Windows VMs)
|
||||
- Transport for remote build execution inside **Windows** build VMs
|
||||
- Port: **5986** (HTTPS, self-signed cert, `AllowUnencrypted=false`)
|
||||
- Authentication: **Basic over HTTPS** (`-SkipCACheck` for lab self-signed cert)
|
||||
- Used for:
|
||||
- Transferring source: `Compress-Archive` on host → WinRM copy → `Expand-Archive` in guest
|
||||
- Running build commands (`Invoke-Command`)
|
||||
- Packaging artifacts (`Compress-Archive` in guest) and collecting via `Copy-Item -FromSession`
|
||||
|
||||
### SSH / SCP Transport (Linux VMs)
|
||||
- Transport for remote build execution inside **Linux** build VMs
|
||||
- Port: **22** (key-based auth, ed25519 key `F:\CI\keys\ci_linux`, user `ci_build`)
|
||||
- No password — `BatchMode=yes`, `StrictHostKeyChecking=accept-new`
|
||||
- Implemented in `scripts/_Transport.psm1` (exported: `Invoke-SshCommand`, `Copy-SshItem`, `Test-SshReady`)
|
||||
- Used by `Wait-VMReady.ps1 -Transport SSH`, `Invoke-RemoteBuild.ps1 -GuestOS Linux`, `Get-BuildArtifacts.ps1 -GuestOS Linux`
|
||||
- Selected automatically when `Invoke-CIJob.ps1` detects `guestOS = ubuntu-64` in the cloned VMX
|
||||
|
||||
### Build Toolchain (inside VM only)
|
||||
- Visual Studio Build Tools **2026** (MSBuild 18.5.4 / toolset v145)
|
||||
- .NET SDK **10.0.203**
|
||||
- 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)
|
||||
- **Git for Windows 2.54** + **7-Zip 26.01** (Tier-1 toolchain, §6.6 — DONE 2026-05-10): consente l'opt-in `-UseGitClone` di `Invoke-CIJob.ps1` per clonare il repository direttamente in VM (skip host zip transfer, e2e PASS, -25.7% durata pipeline).
|
||||
|
||||
---
|
||||
|
||||
@@ -133,8 +143,9 @@ Build VMs:
|
||||
...
|
||||
Clone_Job_N → 192.168.79.1xx
|
||||
|
||||
WinRM port 5985 (HTTP) on each VM IP.
|
||||
VMs have internet access via NAT — required for pip/nuget during build.
|
||||
WinRM port 5986 (HTTPS, self-signed) on each Windows VM IP.
|
||||
SSH port 22 on each Linux VM IP (key-based, ci_build@<ip>, key: F:\CI\keys\ci_linux).
|
||||
VMs have internet access via NAT — required for pip/nuget/apt during build.
|
||||
```
|
||||
|
||||
> **Note:** VMnet8 NAT gives VMs internet access. The host detects the VM IP via
|
||||
@@ -157,7 +168,8 @@ VMs have internet access via NAT — required for pip/nuget during build.
|
||||
Duration: ~20–40 seconds boot │
|
||||
│
|
||||
4. READINESS CHECK │
|
||||
Poll: getState=running → ping → Test-WSMan │
|
||||
Windows: getState=running → ping → Test-WSMan │
|
||||
Linux: getState=running → TCP:22 → ssh echo │
|
||||
Duration: ~30–90 seconds total │
|
||||
│
|
||||
5. BUILD │
|
||||
@@ -191,4 +203,6 @@ VMs have internet access via NAT — required for pip/nuget during build.
|
||||
- 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
|
||||
- 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) by default — no PAT is ever present inside the VM; VMs have NAT internet for pip/nuget at build time.
|
||||
- With `-UseGitClone` (§3.3, opt-in) il clone avviene dentro la VM via SSH alias `gitea-ci` (forwardato dall'host); la chiave privata SSH dell'host non viene esposta al guest — vedi BEST-PRACTICES.md per il threat model.
|
||||
- Linux build VMs usano la chiave SSH `F:\CI\keys\ci_linux` (ed25519, no passphrase), archiviata solo sull'host. Il guest non conosce credenziali WinRM.
|
||||
|
||||
+120
-42
@@ -30,47 +30,121 @@ $cred = Get-StoredCredential -Target 'BuildVMGuest'
|
||||
|
||||
---
|
||||
|
||||
## 2. WinRM Security
|
||||
## 2. WinRM Security — HTTPS/5986 (implementato 2026-05-10)
|
||||
|
||||
### Current setup (HTTP / port 5985)
|
||||
### Setup attuale (HTTPS / port 5986)
|
||||
|
||||
Acceptable for an **isolated lab network** where:
|
||||
- Build VMs sono su VMnet8 NAT (192.168.79.0/24) — raggiungibili dall'host, internet via NAT
|
||||
- No external traffic reaches port 5985
|
||||
- Credentials are managed via Windows Credential Manager
|
||||
`Deploy-WinBuild2025.ps1` post-install.ps1 crea un certificato self-signed e configura
|
||||
il listener HTTPS/5986 **prima** dello snapshot `BaseClean`. `AllowUnencrypted=false`.
|
||||
|
||||
### Recommended upgrade: WinRM over HTTPS (port 5986)
|
||||
- Build VMs su VMnet8 NAT (192.168.79.0/24) — accesso solo dall'host
|
||||
- Port 5986 firewall rule ristretta a `RemoteAddress '192.168.79.0/24'`
|
||||
- Credentials via Windows Credential Manager (target `BuildVMGuest`)
|
||||
|
||||
Tutti gli script host usano:
|
||||
```powershell
|
||||
# Inside the template VM (before taking BaseClean snapshot):
|
||||
|
||||
# Create self-signed certificate
|
||||
$cert = New-SelfSignedCertificate `
|
||||
-Subject "CN=$(hostname)" `
|
||||
-CertStoreLocation Cert:\LocalMachine\My `
|
||||
-KeyUsage DigitalSignature, KeyEncipherment `
|
||||
-KeyAlgorithm RSA `
|
||||
-KeyLength 2048
|
||||
|
||||
# Create HTTPS WinRM listener
|
||||
New-WSManInstance WinRM/Config/Listener `
|
||||
-SelectorSet @{ Transport = 'HTTPS'; Address = '*' } `
|
||||
-ValueSet @{ Hostname = $(hostname); CertificateThumbprint = $cert.Thumbprint }
|
||||
|
||||
# Allow port 5986
|
||||
New-NetFirewallRule -Name 'WinRM-HTTPS-CI' -DisplayName 'WinRM HTTPS CI' `
|
||||
-Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow
|
||||
```
|
||||
|
||||
```powershell
|
||||
# On the HOST (in scripts), use HTTPS session options:
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
$session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL `
|
||||
$session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL -Authentication Basic `
|
||||
-Credential $cred -SessionOption $sessionOptions
|
||||
```
|
||||
|
||||
> `-SkipCACheck` is acceptable for a self-signed cert in an isolated lab. Do NOT
|
||||
> use this against externally accessible machines.
|
||||
> `-SkipCACheck`/`-SkipCNCheck` sono accettabili per un cert self-signed in lab isolato.
|
||||
> Non usare contro macchine accessibili dall'esterno — usare una CA trusted in quel caso.
|
||||
|
||||
---
|
||||
|
||||
## 2.1. Threat Model — Disabled Security Features
|
||||
|
||||
### Current state: Defender, Firewall, and UAC disabled
|
||||
|
||||
The template VM disables Windows Defender, Windows Firewall, and User Account Control (UAC).
|
||||
This is **intentional** — not a bug, not an oversight. Each has tradeoffs:
|
||||
|
||||
| Feature | Disabled? | Why | Cost if enabled |
|
||||
|---------|-----------|-----|-----------------|
|
||||
| **Windows Defender** | Yes | Real-time AV scanning blocks .NET compilation, Python wheels, and npm installs | 5–10 min per build overhead; false positives on dev tools |
|
||||
| **Windows Firewall** | Yes | Blocks inbound WinRM even with rules; requires Domain/Home profile tuning | Complex rules; fragile across OS updates |
|
||||
| **UAC (LocalAccountTokenFilterPolicy)** | Yes | Prevents non-elevated WinRM scripts from running builds | Requires built-in Administrator account; WinRM behaves like a user with limited rights |
|
||||
|
||||
### When this threat model is acceptable
|
||||
|
||||
Current threat model is **safe** if **ALL** of these are true:
|
||||
|
||||
1. **Isolated lab environment** — Build VMs exist only on VMnet8 (NAT), not on host LAN.
|
||||
2. **No shared resources** — Host is not shared with untrusted users or concurrent CI systems.
|
||||
3. **Trusted source code** — Code being built is from trusted repositories (internal team only).
|
||||
4. **No external access** — VMnet8 is not bridged or exposed to corporate LAN or internet.
|
||||
5. **Act_runner is trusted** — The act_runner service token cannot be used to access host resources outside the isolated network.
|
||||
|
||||
If all conditions hold, the attack surface is limited to:
|
||||
- Network eavesdropping on 192.168.79.0/24 (mitigated: WinRM is HTTPS)
|
||||
- Code injection via malicious commits (mitigated: code review process)
|
||||
- Privilege escalation from VM to host (mitigated: VMs are ephemeral; no persistence)
|
||||
|
||||
### When the model breaks down
|
||||
|
||||
**Do NOT use this configuration if:**
|
||||
|
||||
- ❌ **Third-party code builds** — Running untrusted vendor code (open-source projects, third-party libraries with build scripts)
|
||||
- ❌ **Shared build machine** — Other teams or processes share the host CPU/storage
|
||||
- ❌ **LAN-exposed network** — VMnet8 is bridged to corporate LAN or internet
|
||||
- ❌ **Host resource sharing** — Build VMs can access host shares, USB drives, or external storage
|
||||
- ❌ **Long-lived VMs** — VMs are not destroyed after each build (antivirus blind spot for persistence)
|
||||
|
||||
In these scenarios, disabled AV and firewall create **unacceptable risk**.
|
||||
|
||||
### Mitigations if constraints change
|
||||
|
||||
If you must run in a less-isolated environment, re-enable protections **with cost awareness**:
|
||||
|
||||
#### Option 1: Re-enable Firewall only (lowest cost)
|
||||
```powershell
|
||||
# In template VM via WinRM, before taking BaseClean snapshot:
|
||||
Set-NetFirewallProfile -Profile Domain, Public, Private -Enabled $true
|
||||
# Add inbound rule for WinRM listener
|
||||
New-NetFirewallRule -Name "WinRM-HTTPS" `
|
||||
-DisplayName "Windows Remote Management (HTTPS)" `
|
||||
-Direction Inbound `
|
||||
-LocalPort 5986 `
|
||||
-Protocol TCP `
|
||||
-Action Allow
|
||||
```
|
||||
**Cost:** 30–60 seconds per build (firewall rule evaluation + logging).
|
||||
**Benefit:** Blocks outbound malware callbacks if VM is compromised.
|
||||
|
||||
#### Option 2: Re-enable Defender with exclusions (moderate cost)
|
||||
```powershell
|
||||
# In template VM, enable Defender but exclude build directories:
|
||||
Enable-MpComputerDefault # Re-enable Defender
|
||||
Add-MpPreference -ExclusionPath @(
|
||||
'C:\Build',
|
||||
'C:\Users\ci_build\AppData\Local\Microsoft\dotnet',
|
||||
'C:\Users\ci_build\AppData\Roaming\npm'
|
||||
) -Force
|
||||
# Reduce scanning aggressiveness:
|
||||
Set-MpPreference -DisableRealtimeMonitoring $false -DisableBehaviorMonitoring $true
|
||||
```
|
||||
**Cost:** 2–5 min per build (initial scan; exclusions help but don't eliminate overhead).
|
||||
**Benefit:** Detects known malware uploaded in build artifacts.
|
||||
|
||||
#### Option 3: Enable UAC for elevated builds only (requires refactor)
|
||||
```powershell
|
||||
# NOT RECOMMENDED without major refactoring.
|
||||
# WinRM remote commands run as non-elevated user; builds fail.
|
||||
# Requires either:
|
||||
# - Running WinRM as built-in Administrator (security anti-pattern)
|
||||
# - Adding explicit runas prompts (breaks automation)
|
||||
# - Using Windows Task Scheduler instead of WinRM (complexity)
|
||||
```
|
||||
|
||||
### Audit and sign-off
|
||||
|
||||
Before deploying to production or a shared host:
|
||||
|
||||
1. **Document the decision:** Update this section with current date and approver name.
|
||||
2. **Test the mitigations:** Create test clone, enable firewall/AV, measure build time overhead.
|
||||
3. **Establish monitoring:** Run Watch-RunnerHealth.ps1 continuously; alert on service restarts.
|
||||
4. **Plan rotation:** Schedule quarterly credential rotation (see §1 Credential Management).
|
||||
|
||||
---
|
||||
|
||||
@@ -233,27 +307,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
|
||||
reach the internet (important for supply-chain security):
|
||||
Build VMs run on **VMnet8 (NAT)** — they have internet access, which is required
|
||||
for pip/nuget package downloads at build time. Verify the expected topology:
|
||||
|
||||
```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 {
|
||||
$result = Test-Connection 8.8.8.8 -Count 1 -Quiet
|
||||
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 {
|
||||
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:
|
||||
- The host (for WinRM connection)
|
||||
- Gitea server (for git clone, if reachable via host-only network)
|
||||
- NuGet cache share (host-side shared folder)
|
||||
Build VMs can reach:
|
||||
- The host via VMnet8 gateway (WinRM HTTPS on port 5986)
|
||||
- Internet via VMware NAT (for pip, nuget, npm at build time)
|
||||
- Gitea server if on LAN reachable via NAT gateway
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+40
-11
@@ -12,8 +12,10 @@ Before any CI job runs, the following must be in place:
|
||||
| ------------------ | ----------------------------------------------------------------------- |
|
||||
| Gitea server | Running, accessible on LAN |
|
||||
| act_runner | Registered, running as Windows service on host |
|
||||
| Template VM | Provisioned, snapshot "BaseClean" taken, VM powered off |
|
||||
| WinRM | Enabled on template VM (inherits to all clones) |
|
||||
| Template VM (Win) | Provisioned, snapshot "BaseClean" taken, VM powered off |
|
||||
| Template VM (Lin) | Ubuntu 24.04 LTS, snapshot "BaseClean-Linux" taken, VM powered off (optional) |
|
||||
| WinRM | Enabled on Windows template VM (inherits to all clones) |
|
||||
| SSH key | `F:\CI\keys\ci_linux` (ed25519) present; public key in Linux template |
|
||||
| vmrun.exe | Present at `C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe` |
|
||||
| `F:\CI\BuildVMs\` | Directory exists and writable |
|
||||
| `F:\CI\Artifacts\` | Directory exists and writable |
|
||||
@@ -110,12 +112,19 @@ Wait-VMReady.ps1 -VMPath <clone.vmx> -IPAddress <dynamic-NAT-ip> -TimeoutSeconds
|
||||
IP is discovered dynamically via `vmrun getGuestIPAddress` (VMnet8 NAT, subnet 192.168.79.0/24).
|
||||
No hardcoded IPs — each clone gets a DHCP-assigned address from VMware NAT.
|
||||
|
||||
Three-phase readiness check (retried every 5 seconds, up to 300s timeout):
|
||||
Three-phase readiness check (retried every 5 seconds, up to 300s timeout).
|
||||
Transport is selected automatically from the VMX `guestOS` field (`-Transport WinRM` for Windows, `-Transport SSH` for Linux):
|
||||
|
||||
```
|
||||
Phase 1: vmrun getState → must return "running"
|
||||
Phase 2: Test-Connection (ICMP ping) → must succeed
|
||||
Phase 3: Test-WSMan → WinRM listener must respond
|
||||
|
||||
Windows (-Transport WinRM, default):
|
||||
Phase 2: Test-Connection (ICMP ping) → must succeed
|
||||
Phase 3: Test-WSMan → WinRM listener must respond
|
||||
|
||||
Linux (-Transport SSH):
|
||||
Phase 2: Test-NetConnection -Port 22 → must succeed
|
||||
Phase 3: ssh -o ConnectTimeout=5 ... "echo ready" → must return "ready"
|
||||
```
|
||||
|
||||
- Total wait is typically **30–90 seconds** after `vmrun start`
|
||||
@@ -129,17 +138,29 @@ Phase 3: Test-WSMan → WinRM listener must respond
|
||||
```
|
||||
Invoke-RemoteBuild.ps1
|
||||
-IPAddress <dynamic-NAT-ip> # from vmrun getGuestIPAddress
|
||||
-GuestOS Windows|Linux # auto-detected from VMX guestOS field
|
||||
|
||||
# Windows path (default):
|
||||
-Credential (from Windows Credential Manager)
|
||||
-BuildCommand 'python build_plugin.py --final --dist-dir dist'
|
||||
-GuestArtifactSource 'dist'
|
||||
|
||||
# Linux path (-GuestOS Linux):
|
||||
-SshKeyPath F:\CI\keys\ci_linux
|
||||
-SshUser ci_build
|
||||
-CloneUrl / -CloneBranch # always in-VM git clone for Linux
|
||||
-BuildCommand 'make all'
|
||||
```
|
||||
|
||||
Sequence inside the PSSession:
|
||||
|
||||
```
|
||||
1. New-PSSession → open WinRM session to VM
|
||||
2. Compress-Archive (host) → copy zip to C:\CI\build.zip (guest) → Expand-Archive to C:\CI\build\
|
||||
(source code è già clonato sull'host in Phase 1 — non si fa git clone nella VM)
|
||||
2a. (default) Compress-Archive (host) → copy zip to C:\CI\build.zip (guest) → Expand-Archive to C:\CI\build\
|
||||
(source code è già clonato sull'host in Phase 1 — nessun git clone nella VM)
|
||||
2b. (opt-in, `-UseGitClone`, §3.3 DONE 2026-05-10)
|
||||
git clone direttamente in VM tramite `ssh://gitea-ci/...` (richiede Git+7-Zip in template, §6.6 Tier-1)
|
||||
Vantaggio: skip host-zip-transfer, -25.7% durata pipeline (e2e PASS). Verifica SHA commit post-clone.
|
||||
3. Invoke-Expression "$BuildCommand" in C:\CI\build\
|
||||
Esempio: python build_plugin.py --final --dist-dir dist
|
||||
a. Build 4 configurazioni MSBuild in parallelo (x86-unicode, x64-unicode, cli-x86, cli-x64)
|
||||
@@ -160,14 +181,22 @@ Sequence inside the PSSession:
|
||||
```
|
||||
Get-BuildArtifacts.ps1
|
||||
-IPAddress <dynamic-NAT-ip>
|
||||
-Credential (same as above)
|
||||
-GuestOS Windows|Linux # auto-detected from Invoke-CIJob
|
||||
|
||||
# Windows path (default):
|
||||
-Credential (from Windows Credential Manager)
|
||||
-GuestArtifactPath C:\CI\output\artifacts.zip
|
||||
|
||||
# Linux path (-GuestOS Linux):
|
||||
-SshKeyPath F:\CI\keys\ci_linux
|
||||
-GuestArtifactPath /opt/ci/output
|
||||
|
||||
-HostArtifactDir F:\CI\Artifacts\{JobId}\
|
||||
```
|
||||
|
||||
- Apre PSSession e copia `C:\CI\output\artifacts.zip` dall'host
|
||||
- Valida che il file esista e sia non-vuoto
|
||||
- Il zip contiene la struttura `dist/` con tutti gli artifact di build
|
||||
- Windows: apre PSSession e copia `C:\CI\output\artifacts.zip` tramite WinRM
|
||||
- Linux: `scp -r ci_build@<ip>:/opt/ci/output/. <HostArtifactDir>` tramite `_Transport.psm1`
|
||||
- In entrambi i casi valida che almeno un file sia stato trasferito
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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\
|
||||
│ ├── WinBuild2025\ # VMX template Windows (immutabile dopo snapshot)
|
||||
│ └── LinuxBuild2404\ # VMX template Linux Ubuntu 24.04 (immutabile dopo snapshot)
|
||||
├── keys\
|
||||
│ ├── ci_linux # chiave privata SSH per Linux VMs (ed25519, no passphrase)
|
||||
│ └── ci_linux.pub # chiave pubblica corrispondente
|
||||
├── 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 (§3.1 — DONE 2026-05-10, vedi scripts/Set-TemplateSharedFolders.ps1)
|
||||
├── 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
|
||||
# AllowUnencrypted non serve — Prepare usa HTTPS/5986 (nessun testo in chiaro)
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate
|
||||
```
|
||||
*Note*: Prepare-WinBuild2025.ps1 aggiunge l'IP specifico della VM in modo transitorio e lo ripristina nel `finally`.
|
||||
|
||||
4. **Provisiona il template VM Windows** — vedi [WINDOWS-TEMPLATE-SETUP.md](WINDOWS-TEMPLATE-SETUP.md)
|
||||
|
||||
5. **(Opzionale) Genera chiave SSH CI per Linux VMs** — necessaria solo se si vuole usare `linux-build`:
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force F:\CI\keys | Out-Null
|
||||
ssh-keygen -t ed25519 -f F:\CI\keys\ci_linux -N '""' -C 'ci-linux-runner'
|
||||
# Risultato: F:\CI\keys\ci_linux (privata) + F:\CI\keys\ci_linux.pub (pubblica)
|
||||
```
|
||||
Poi **provisiona il template VM Linux** — vedi [LINUX-TEMPLATE-SETUP.md](LINUX-TEMPLATE-SETUP.md)
|
||||
|
||||
6. **Registra chiave SSH con Gitea**:
|
||||
- Gitea UI → User Settings → SSH Keys → Add Key
|
||||
- Chiave pubblica: `%USERPROFILE%\.ssh\id_rsa.pub`
|
||||
|
||||
7. **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 (Windows) | `F:\CI\Templates\WinBuild2025\WinBuild2025.vmx` |
|
||||
| Template VMX (Linux) | `F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx` |
|
||||
| Snapshot name (Windows) | `BaseClean` |
|
||||
| Snapshot name (Linux) | `BaseClean-Linux` |
|
||||
| SSH key (Linux VMs) | `F:\CI\keys\ci_linux` (privata), `F:\CI\keys\ci_linux.pub` |
|
||||
| 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
|
||||
|
||||
Task schedulati (vedi `scripts/Register-CIScheduledTasks.ps1` per il bootstrap):
|
||||
|
||||
- **§2.2** Cleanup VM orfane (`scripts/Cleanup-OrphanedBuildVMs.ps1`, ogni 6h, `-MaxAgeHours 4`)
|
||||
- **§2.3** Retention artifact + log (`scripts/Invoke-RetentionPolicy.ps1`, daily)
|
||||
- **§2.6** Backup template (`scripts/Backup-CITemplate.ps1`, weekly)
|
||||
- **§2.7** Health check runner (`scripts/Watch-RunnerHealth.ps1`, ogni 15 min)
|
||||
- **§4.3** Disk space alert (`scripts/Watch-DiskSpace.ps1`, ogni ora)
|
||||
|
||||
---
|
||||
|
||||
## 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,426 @@
|
||||
# Linux Template VM Setup Piano di implementazione
|
||||
|
||||
> **Status**: implementato (2026-05-11) — Fasi A-E complete. Fase F (test e2e) richiede provisioning fisico VM. Vedi [TODO.md §6.1](../TODO.md) (P2).
|
||||
> Distro scelta: **Ubuntu 24.04 LTS** (cloud image, cloud-init). Approccio simmetrico a Windows.
|
||||
|
||||
---
|
||||
|
||||
## Architettura: tre script + modulo transport
|
||||
|
||||
| Script / Modulo | Dove gira | Ruolo |
|
||||
| ------------------------------------- | ---------------- | --------------------------------------------------------------------------- |
|
||||
| `template/Deploy-LinuxBuild2404.ps1` | **Host** (PS) | Crea VM da cloud image VMDK, genera seed ISO cloud-init, avvia, aspetta SSH |
|
||||
| `template/Prepare-LinuxBuild2404.ps1` | **Host** (PS) | Copia + esegue setup via SSH, valida stato, prende snapshot |
|
||||
| `template/Setup-LinuxBuild2404.sh` | **Guest** (Bash) | Installa toolchain CI, dirs, hardening, gate finale |
|
||||
| `scripts/_Transport.psm1` | **Host** (PS) | Funzioni SSH-only per il branch Linux (WinRM esistente invariato per ora) |
|
||||
|
||||
Perche tre script separati (non tutto-in-uno): cloud-init copre solo il bootstrap OS
|
||||
(utente, SSH key, pacchetti base). Il toolchain CI e le validazioni per step sono piu
|
||||
leggibili e manutenibili in un secondo script dedicato identico alla divisione
|
||||
Deploy/Setup/Prepare di Windows.
|
||||
|
||||
---
|
||||
|
||||
## Specifiche VM
|
||||
|
||||
| Parametro | Valore |
|
||||
| ------------- | ---------------------------------------------------- |
|
||||
| OS | Ubuntu 24.04 LTS (cloud image, non ISO installer) |
|
||||
| Immagine base | `noble-server-cloudimg-amd64.vmdk` |
|
||||
| Download | `https://cloud-images.ubuntu.com/noble/current/` |
|
||||
| vCPU | 4 |
|
||||
| RAM | 4096 MB |
|
||||
| Disco | 40 GB thin (resize del cloud VMDK) |
|
||||
| NIC | VMnet8 (NAT) vmxnet3 diretto (driver nativo kernel) |
|
||||
| VMX path | `F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx` |
|
||||
| Snapshot name | `BaseClean-Linux` |
|
||||
|
||||
Differenza chiave da Windows: l'immagine di partenza e gia installata (cloud VMDK,
|
||||
~600 MB). Nessuna fase di installazione OS cloud-init provvede al bootstrap al primo
|
||||
boot in 30-60 secondi. La NIC non richiede lo swap e1000e vmxnet3 perche il kernel
|
||||
Linux ha il driver vmxnet3 nativo.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisiti host
|
||||
|
||||
- [x] **SSH key CI dedicata** generare una volta sola (no passphrase):
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force F:\CI\keys | Out-Null
|
||||
ssh-keygen -t ed25519 -f F:\CI\keys\ci_linux -N '""' -C 'ci-linux-runner'
|
||||
# Risultato: F:\CI\keys\ci_linux (privata) + F:\CI\keys\ci_linux.pub (pubblica)
|
||||
```
|
||||
- [x] **OpenSSH client** verificato presente sull'host (default su Windows 11):
|
||||
```powershell
|
||||
Get-WindowsCapability -Online -Name OpenSSH.Client* | Select-Object State
|
||||
# Atteso: State = Installed
|
||||
```
|
||||
- [x] **`F:\CI\Templates\LinuxBuild2404\`** creata (Setup-Host.ps1 o manuale)
|
||||
- [x] **`F:\CI\ISO\`** creata (il VMDK viene scaricato automaticamente da Deploy se mancante)
|
||||
|
||||
---
|
||||
|
||||
## Fase A Deploy-LinuxBuild2404.ps1
|
||||
|
||||
Script host-side. Produce una VM pronta con cloud-init completato e SSH raggiungibile.
|
||||
Non richiede installazione OS parte dal cloud VMDK pre-built.
|
||||
|
||||
- [x] **Step 1 Validate prerequisites**
|
||||
- Parametro `-VMwareWorkstationDir` (default `C:\Program Files (x86)\VMware\VMware Workstation`) — stesso pattern di Deploy-WinBuild2025.ps1
|
||||
- `vmrun.exe` presente in `$VMwareWorkstationDir`
|
||||
- `vmware-vdiskmanager.exe` presente in `$VMwareWorkstationDir`
|
||||
(`$vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe'`)
|
||||
- SSH key `F:\CI\keys\ci_linux.pub` presente
|
||||
- Cartella destinazione VMX non gia occupata (o `-Force` per sovrascrivere)
|
||||
- (il VMDK sorgente viene gestito in Step 1b — nessun prerequisito manuale)
|
||||
|
||||
- [x] **Step 1b Download cloud VMDK se assente** (`F:\CI\ISO\` come cache locale)
|
||||
```powershell
|
||||
$VmdkUrl = 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.vmdk'
|
||||
$VmdkPath = 'F:\CI\ISO\noble-server-cloudimg-amd64.vmdk'
|
||||
if (-not (Test-Path $VmdkPath)) {
|
||||
Write-Host "[Deploy] VMDK non trovato, avvio download..."
|
||||
New-Item -ItemType Directory -Force F:\CI\ISO | Out-Null
|
||||
$tmp = "$VmdkPath.part"
|
||||
try {
|
||||
Invoke-WebRequest -Uri $VmdkUrl -OutFile $tmp -UseBasicParsing
|
||||
Move-Item $tmp $VmdkPath
|
||||
Write-Host "[Deploy] Download completato: $VmdkPath"
|
||||
} catch {
|
||||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||
throw "[Deploy] Download VMDK fallito: $_"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[Deploy] VMDK trovato in cache: $VmdkPath"
|
||||
}
|
||||
```
|
||||
- Usa `Invoke-WebRequest` (nativo PowerShell, no dipendenze esterne)
|
||||
- Scrittura atomica: file `.part` durante download, rinomina solo a completamento
|
||||
- In caso di errore: il file `.part` viene rimosso, l'eccezione termina lo script
|
||||
- Parametro opzionale `-VmdkUrl` per sovrascrivere l'URL (es. mirror locale)
|
||||
|
||||
- [x] **Step 2 Genera cloud-init user-data + meta-data** (inline, no template esterno)
|
||||
- `meta-data`: `instance-id: linuxbuild-001`, `local-hostname: ci-linux-template`
|
||||
- `user-data` (YAML):
|
||||
- `hostname: ci-linux-template`
|
||||
- `users`: utente `ci_build`, shell `/bin/bash`, `sudo: ALL=(ALL) NOPASSWD:ALL`,
|
||||
`ssh_authorized_keys` con contenuto di `ci_linux.pub`
|
||||
- Nessun `packages`, nessun `package_update`, nessun `runcmd`:
|
||||
tutto il provisioning (apt, toolchain, SSH hardening, swap) e' delegato a
|
||||
`Prepare-LinuxBuild2404.ps1` via `Setup-LinuxBuild2404.sh`
|
||||
- Nessun `power_state`: il reboot non e' necessario, cloud-init completa tutto al primo boot
|
||||
|
||||
- [x] **Step 3 Costruisce seed ISO nocloud** via IMAPI2FS COM
|
||||
- Due file: `user-data` + `meta-data` (volume label: `cidata`)
|
||||
- Stesso codice COM gia usato in Deploy-WinBuild2025.ps1 Step 3
|
||||
- Output: `F:\CI\Templates\LinuxBuild2404\cloud-init-seed.iso`
|
||||
|
||||
- [x] **Step 4 Converti e ridimensiona cloud VMDK**
|
||||
- Il cloud VMDK e' in formato **streamOptimized** (distribuzione), non direttamente resizeable.
|
||||
- Converti a **monolithicSparse** (tipo 0) contestualmente alla copia:
|
||||
`& $vdiskMgr -r noble-server-cloudimg-amd64.vmdk -t 0 LinuxBuild2404.vmdk`
|
||||
- Ridimensiona a 40 GB: `& $vdiskMgr -x 40GB LinuxBuild2404.vmdk`
|
||||
(stessa variabile `$vdiskMgr` di Deploy-WinBuild2025.ps1, valorizzata in Step 1)
|
||||
- Il resize avviene PRIMA del primo boot: l'immagine Ubuntu 24.04 ha i moduli
|
||||
cloud-init `growpart` + `resizefs` abilitati di default — espandono automaticamente
|
||||
la partizione root e il filesystem durante il primo avvio, senza `runcmd` aggiuntivi
|
||||
|
||||
- [x] **Step 5 Genera .vmx**
|
||||
- `guestOS = "ubuntu-64"`, `firmware = "efi"`
|
||||
(Ubuntu 24.04 cloud image usa GPT con partizione EFI; il boot sotto BIOS legacy fallirebbe)
|
||||
- NIC: `vmxnet3`, VMnet8
|
||||
- Disco: `LinuxBuild2404.vmdk` (scsi0:0)
|
||||
- CD-ROM secondario: `cloud-init-seed.iso` (connesso)
|
||||
- Nessun USB controller, nessuna sound card
|
||||
- `memsize = 4096`, `numvcpus = 4`
|
||||
|
||||
- [x] **Step 6 Power on** (`vmrun start nogui`)
|
||||
|
||||
- [x] **Step 7 Polling SSH port 22** fino a ready
|
||||
- `Test-NetConnection -ComputerName $IP -Port 22` ogni 10 secondi
|
||||
- Timeout: 5 minuti (cloud-init + reboot finale)
|
||||
- IP rilevato via `vmrun getGuestIPAddress`
|
||||
|
||||
> open-vm-tools e pre-installato nell'Ubuntu 24.04 cloud image.
|
||||
> `vmrun getGuestIPAddress` funziona senza installazione aggiuntiva.
|
||||
|
||||
- [x] **Step 8 Verifica cloud-init completato**
|
||||
```powershell
|
||||
ssh -i F:\CI\keys\ci_linux -o StrictHostKeyChecking=accept-new `
|
||||
-o ConnectTimeout=5 ci_build@$IP `
|
||||
"test -f /var/lib/cloud/instance/boot-finished && echo OK"
|
||||
# Atteso: OK
|
||||
```
|
||||
|
||||
- [x] **Step 9 Rimuove seed ISO dal VMX**
|
||||
- Setta `ide1:0.present = "FALSE"` nel VMX
|
||||
- Evita che linked clone ricevano il seed ISO e ri-eseguano cloud-init
|
||||
|
||||
- [x] **Step 10 Print summary** (VMX path, IP, SSH fingerprint)
|
||||
|
||||
---
|
||||
|
||||
## Fase B Prepare-LinuxBuild2404.ps1
|
||||
|
||||
Script host-side. Copia il setup script, lo esegue via SSH, valida stato, prende snapshot.
|
||||
Equivalente di `Prepare-WinBuild2025.ps1` con SSH al posto di WinRM.
|
||||
|
||||
- [x] **Step 1 Pre-flight**
|
||||
- VMX presente; se VM spenta, avvia con `vmrun start nogui`
|
||||
- SSH key `F:\CI\keys\ci_linux` presente
|
||||
- `ssh.exe` e `scp.exe` nel PATH host
|
||||
- TCP/22 raggiungibile (`Test-NetConnection`)
|
||||
|
||||
- [x] **Step 2 Auto-detect IP** via `vmrun getGuestIPAddress $VMXPath`
|
||||
- Se `-VMIPAddress` e fornito esplicitamente, salta il rilevamento automatico
|
||||
|
||||
- [x] **Step 3 Aggiungi host key a known_hosts**
|
||||
```powershell
|
||||
ssh -i F:\CI\keys\ci_linux -o StrictHostKeyChecking=accept-new `
|
||||
ci_build@$IP "echo connected" | Out-Null
|
||||
```
|
||||
Solo al primo run; idempotente se gia presente.
|
||||
|
||||
- [x] **Step 4 Copia Setup-LinuxBuild2404.sh in VM**
|
||||
```powershell
|
||||
scp -i F:\CI\keys\ci_linux `
|
||||
"$PSScriptRoot\Setup-LinuxBuild2404.sh" `
|
||||
"ci_build@${IP}:/tmp/Setup-LinuxBuild2404.sh"
|
||||
```
|
||||
Valida: confronta size locale vs remota via `ssh ... stat -c %s /tmp/...`
|
||||
|
||||
- [x] **Step 5 Esegui setup script** nel guest
|
||||
```powershell
|
||||
ssh -i F:\CI\keys\ci_linux ci_build@$IP `
|
||||
"chmod +x /tmp/Setup-LinuxBuild2404.sh && sudo /tmp/Setup-LinuxBuild2404.sh"
|
||||
# Cattura exit code; != 0 => abort con errore leggibile
|
||||
```
|
||||
Flag opzionali passabili: `--skip-update`, `--dotnet`, `--mingw`
|
||||
|
||||
- [x] **Step 6 Post-setup remote validation** (batch via singola sessione SSH)
|
||||
- `systemctl is-active ssh` = `active`
|
||||
- `id ci_build` contiene `sudo`
|
||||
- `sudo -n true` exit 0 (passwordless sudo)
|
||||
- `which gcc g++ clang cmake python3 git 7z` tutti trovati
|
||||
- `test -d /opt/ci/build -a -d /opt/ci/output -a -d /opt/ci/scripts` exit 0
|
||||
- `swapon --show` output vuoto (swap disabilitato)
|
||||
- `grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config`
|
||||
|
||||
- [x] **Step 7 Shutdown VM**
|
||||
```powershell
|
||||
& $vmrun stop $VMXPath soft
|
||||
# Polling vmrun list fino a VM non piu presente
|
||||
```
|
||||
|
||||
- [x] **Step 8 Snapshot BaseClean-Linux**
|
||||
```powershell
|
||||
& $vmrun snapshot $VMXPath 'BaseClean-Linux'
|
||||
```
|
||||
|
||||
- [x] **Final Print summary** (VMX, snapshot name, toolchain versions rilevate)
|
||||
|
||||
---
|
||||
|
||||
## Fase C Setup-LinuxBuild2404.sh
|
||||
|
||||
Script guest-side (Bash). Installazione toolchain CI e hardening.
|
||||
Eseguito da Prepare via SSH come `ci_build` con sudo.
|
||||
|
||||
Header: `#!/usr/bin/env bash` + `set -euo pipefail`
|
||||
Helper `assert_step()`: stampa `[OK] [Step N] desc` su successo, `exit 1` su fallimento.
|
||||
|
||||
- [x] **Step 1 apt update + upgrade** (saltabile con `--skip-update`)
|
||||
```bash
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
|
||||
```
|
||||
|
||||
- [x] **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
|
||||
```
|
||||
- assert: `command -v gcc`, `command -v clang`, `command -v cmake`
|
||||
|
||||
- [x] **Step 3 Python**
|
||||
```bash
|
||||
sudo apt-get install -y -qq python3 python3-pip python3-venv
|
||||
sudo ln -sf /usr/bin/python3 /usr/local/bin/python
|
||||
```
|
||||
- assert: `python3 --version`, `pip3 --version`
|
||||
|
||||
- [x] **Step 4 Tier-1 Toolchain** (speculare a §6.6 Windows: Git + 7-Zip)
|
||||
```bash
|
||||
sudo apt-get install -y -qq git p7zip-full curl wget ca-certificates
|
||||
```
|
||||
- assert: `command -v git`, `command -v 7z`
|
||||
|
||||
- [x] **Step 4b .NET SDK** (opzionale, flag `--dotnet`)
|
||||
```bash
|
||||
curl -fsSL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh \
|
||||
| sudo bash -s -- --channel 10.0 --install-dir /usr/local/dotnet
|
||||
sudo ln -sf /usr/local/dotnet/dotnet /usr/local/bin/dotnet
|
||||
```
|
||||
- assert: `dotnet --version`
|
||||
|
||||
- [x] **Step 4c mingw-w64** (opzionale, flag `--mingw` cross-compile per Windows)
|
||||
```bash
|
||||
sudo apt-get install -y -qq mingw-w64
|
||||
```
|
||||
- assert: `command -v x86_64-w64-mingw32-gcc`
|
||||
|
||||
- [x] **Step 5 CI directories**
|
||||
```bash
|
||||
sudo mkdir -p /opt/ci/{build,output,scripts,cache}
|
||||
sudo chown -R ci_build:ci_build /opt/ci
|
||||
```
|
||||
- assert: `test -d /opt/ci/build`, `test -d /opt/ci/output`, `test -d /opt/ci/scripts`
|
||||
|
||||
- [x] **Step 6 SSH hardening** (PasswordAuth off gia impostato da cloud-init, ri-validato)
|
||||
```bash
|
||||
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
||||
sudo sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
|
||||
sudo systemctl restart sshd
|
||||
```
|
||||
- assert: `grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config`
|
||||
|
||||
- [x] **Step 7 Disabilita swap permanente**
|
||||
```bash
|
||||
sudo swapoff -a
|
||||
sudo sed -i '/ swap / s/^/#/' /etc/fstab
|
||||
```
|
||||
- assert: `swapon --show` output vuoto
|
||||
|
||||
- [x] **Step 8 Disabilita apt auto-update** (no lock durante build)
|
||||
```bash
|
||||
sudo systemctl mask apt-daily.service apt-daily-upgrade.service \
|
||||
apt-daily.timer apt-daily-upgrade.timer
|
||||
sudo apt-get remove -y unattended-upgrades 2>/dev/null || true
|
||||
```
|
||||
|
||||
- [x] **Step 9 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
|
||||
```
|
||||
|
||||
- [x] **Final Gate pre-snapshot** (exit 1 se qualcosa manca)
|
||||
- Ogni tool richiesto raggiungibile con `command -v`
|
||||
- `/opt/ci/{build,output,scripts}` esistenti con owner `ci_build`
|
||||
- `swapon --show` vuoto
|
||||
- `sshd` attivo
|
||||
- `PasswordAuthentication no` in sshd_config
|
||||
- Root filesystem >= 38 GB (verifica che cloud-init `growpart` abbia funzionato):
|
||||
```bash
|
||||
df -BG / | awk 'NR==2{gsub("G","",$2); if ($2+0 < 38) {print "FAIL: root fs too small"; exit 1}}'
|
||||
```
|
||||
- Nessun installer residuo in `/tmp`
|
||||
|
||||
---
|
||||
|
||||
## Fase D Adattamento script CI
|
||||
|
||||
- [x] **`scripts/_Transport.psm1`** nuovo modulo SSH-only per il branch Linux.
|
||||
Gli script Windows esistenti NON vengono modificati ora; il refactoring WinRM e' rinviato.
|
||||
```powershell
|
||||
function Invoke-SshCommand { param($IP, $User, $KeyPath, $Command, ...) }
|
||||
function Copy-SshItem { param($Source, $Dest, $IP, $User, $KeyPath, [ValidateSet('ToGuest','FromGuest')] $Direction, ...) }
|
||||
function Test-SshReady { param($IP, $User, $KeyPath, $TimeoutSec, ...) }
|
||||
```
|
||||
Gli script CI (`Invoke-CIJob`, `Invoke-RemoteBuild`, `Get-BuildArtifacts`) chiameranno
|
||||
queste funzioni nel branch Linux senza toccare il branch WinRM esistente.
|
||||
|
||||
- [x] **`scripts/Invoke-CIJob.ps1`** aggiungere parametro `-GuestOS Windows|Linux`
|
||||
- Auto-detect da VMX `guestOS` field se non fornito
|
||||
- Branch su transport (WinRM vs SSH) in ogni fase
|
||||
|
||||
- [x] **`scripts/Wait-VMReady.ps1`** aggiungere `-Transport SSH`
|
||||
- Probe: `Test-NetConnection -Port 22` + `ssh -o ConnectTimeout=5 ... "echo ok"`
|
||||
|
||||
- [x] **`scripts/Invoke-RemoteBuild.ps1`** branch SSH+scp per Linux
|
||||
- Transfer: `scp` invece di `Copy-Item -ToSession`
|
||||
- Build: `ssh ... "cd /opt/ci/build && $BuildCommand"`
|
||||
|
||||
- [x] **`scripts/Get-BuildArtifacts.ps1`** branch `scp` per Linux
|
||||
- `scp -r ci_build@$IP:/opt/ci/output/* $LocalArtifactDir\`
|
||||
|
||||
- [ ] **`scripts/New-BuildVM.ps1`** gia OS-agnostic (vmrun clone)
|
||||
- Verificare che `-SnapshotName BaseClean-Linux` funzioni correttamente
|
||||
|
||||
- [ ] **`scripts/Remove-BuildVM.ps1`** gia OS-agnostic, nessuna modifica prevista
|
||||
|
||||
---
|
||||
|
||||
## Fase E Runner e workflow Gitea
|
||||
|
||||
- [x] **Label `linux-build`** aggiunta al runner in `runner/config.yaml`:
|
||||
```yaml
|
||||
labels:
|
||||
- 'windows-build:host'
|
||||
- 'linux-build:host'
|
||||
```
|
||||
|
||||
- [x] **Variabile d'ambiente** `GITEA_CI_LINUX_TEMPLATE_PATH` nel servizio act_runner:
|
||||
`F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx`
|
||||
|
||||
- [x] **Workflow matrix** di esempio (`gitea/workflow-example.yml` aggiornato):
|
||||
```yaml
|
||||
strategy:
|
||||
matrix:
|
||||
target: [windows, linux]
|
||||
runs-on: ${{ matrix.target }}-build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase F Test e2e
|
||||
|
||||
- [ ] **Smoke test** linked clone + SSH ready:
|
||||
```powershell
|
||||
.\scripts\New-BuildVM.ps1 -JobId 'linux-smoke-001' `
|
||||
-TemplatePath 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' `
|
||||
-SnapshotName 'BaseClean-Linux'
|
||||
```
|
||||
|
||||
- [ ] **Build test** comando reale nella VM:
|
||||
```powershell
|
||||
.\scripts\Invoke-CIJob.ps1 -JobId 'linux-e2e-001' -GuestOS Linux `
|
||||
-RepoUrl 'ssh://gitea-ci/Simone/<repo>.git' -Branch 'main' `
|
||||
-TemplatePath 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' `
|
||||
-BuildCommand 'make -C /opt/ci/build all' `
|
||||
-GuestArtifactSource '/opt/ci/output'
|
||||
```
|
||||
|
||||
- [ ] **Cleanup** linked clone rimosso dopo build (`Remove-BuildVM.ps1`)
|
||||
- [ ] **Matrix build** run parallela Windows + Linux sullo stesso push
|
||||
|
||||
---
|
||||
|
||||
## Differenze da Windows riepilogo
|
||||
|
||||
| Aspetto | Windows | Linux |
|
||||
| ----------------------- | ----------------------------------------------------- | ---------------------------------------------- |
|
||||
| Immagine di partenza | ISO installer (Deploy ~60-90 min unattended) | Cloud VMDK pre-built (boot + cloud-init ~60 s) |
|
||||
| Provisioning unattended | `autounattend.xml` (Windows Setup) | cloud-init `user-data` (seed ISO nocloud) |
|
||||
| Transport host VM | WinRM HTTPS/5986 + PSSession | `ssh.exe` + `scp.exe` (built-in Windows 11) |
|
||||
| NIC strategia | e1000e install vmxnet3 swap post-Tools | vmxnet3 diretto (driver nativo kernel) |
|
||||
| Setup script | `.ps1` (PowerShell, via WinRM) | `.sh` (Bash, via SSH) |
|
||||
| Rilevamento IP | `vmrun getGuestIPAddress` (VMware Tools) | `vmrun getGuestIPAddress` (open-vm-tools) |
|
||||
| Wait completamento | polling flag file via `vmrun copyFileFromGuestToHost` | polling SSH + `boot-finished` cloud-init |
|
||||
| CI dirs | `C:\CI\{build,output,scripts}` | `/opt/ci/{build,output,scripts,cache}` |
|
||||
| Snapshot name | `BaseClean` | `BaseClean-Linux` |
|
||||
|
||||
---
|
||||
|
||||
## Riferimenti
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) diagramma componenti (aggiornare dopo implementazione)
|
||||
- [CI-FLOW.md](CI-FLOW.md) flusso pipeline (aggiornare Step 0 prerequisites)
|
||||
- [HOST-SETUP.md](HOST-SETUP.md) Setup-Host.ps1 (aggiungere step generazione SSH key)
|
||||
- [TODO.md §6.1](../TODO.md) tracking implementazione
|
||||
@@ -104,7 +104,7 @@ F:\CI\Cache\NuGet\ ← shared folder on host
|
||||
|
||||
### VMware Shared Folder Configuration (in template VMX)
|
||||
|
||||
Add to `WinBuild.vmx` before taking the `BaseClean` snapshot:
|
||||
Add to `WinBuild2025.vmx` before taking the `BaseClean` snapshot:
|
||||
```ini
|
||||
sharedFolder0.present = "TRUE"
|
||||
sharedFolder0.enabled = "TRUE"
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
# CI System Runbook
|
||||
|
||||
Triage guide for the local CI/CD system. Each entry: symptom → triage commands → fix → escalation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Runner offline in Gitea UI
|
||||
|
||||
**Symptom**: `http://10.10.20.11:3100/admin/runners` shows `local-windows-runner` as offline.
|
||||
Queued jobs stay pending indefinitely.
|
||||
|
||||
**Triage**:
|
||||
```powershell
|
||||
# Check service state
|
||||
Get-Service act_runner
|
||||
|
||||
# Last 50 lines of runner log
|
||||
Get-Content 'F:\CI\act_runner\logs\act_runner.log' -Tail 50
|
||||
|
||||
# Check registration file is intact
|
||||
Test-Path 'F:\CI\act_runner\.runner'
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
```powershell
|
||||
# Restart the service
|
||||
Restart-Service act_runner
|
||||
|
||||
# Verify it came back online (wait ~10s then check Gitea UI)
|
||||
Get-Service act_runner | Select-Object Status, StartType
|
||||
|
||||
# If service won't start, check NSSM log
|
||||
& 'C:\nssm\nssm.exe' status act_runner
|
||||
```
|
||||
|
||||
If the `.runner` registration file is missing or corrupt, re-register:
|
||||
```powershell
|
||||
cd F:\CI\act_runner
|
||||
.\act_runner.exe register --no-interactive `
|
||||
--instance http://10.10.20.11:3100 `
|
||||
--token <token-from-gitea-admin> `
|
||||
--name local-windows-runner `
|
||||
--labels "windows-build:host,dotnet:host,msbuild:host"
|
||||
```
|
||||
|
||||
**Escalation**: If the runner restarts but goes offline again within minutes, check Event Viewer → Application for `act_runner` errors and inspect `F:\CI\act_runner\logs\`.
|
||||
|
||||
---
|
||||
|
||||
## 2. All builds fail in Phase 2 (VM clone / start)
|
||||
|
||||
**Symptom**: `Invoke-CIJob.ps1` fails at Phase 2 with errors like:
|
||||
- `vmrun clone failed`
|
||||
- `vmrun start failed`
|
||||
- `Template VMX not found`
|
||||
- `Could not detect VM IP address`
|
||||
|
||||
**Triage**:
|
||||
```powershell
|
||||
# List all running VMs
|
||||
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' -T ws list
|
||||
|
||||
# Check template VMX exists and is accessible
|
||||
Test-Path 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx'
|
||||
|
||||
# Check for orphaned clones that may be consuming disk
|
||||
Get-ChildItem 'F:\CI\BuildVMs\' -Directory | Select-Object Name, LastWriteTime
|
||||
|
||||
# Check disk free space
|
||||
Get-PSDrive F | Select-Object Name, Free, Used
|
||||
|
||||
# Check for a stuck vm-start lock from a crashed job
|
||||
Test-Path 'F:\CI\State\vm-start.lock'
|
||||
```
|
||||
|
||||
**Fix** — by root cause:
|
||||
|
||||
*Template VMX missing/moved*: check `GITEA_CI_TEMPLATE_PATH` in `F:\CI\act_runner\config.yaml`.
|
||||
|
||||
*Parent VMDK locked* (VMware left a lock file after host crash):
|
||||
```powershell
|
||||
# Stop all VMs
|
||||
& vmrun.exe -T ws stop 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' hard
|
||||
# Delete lock files
|
||||
Remove-Item 'F:\CI\Templates\WinBuild2025\*.lck' -Recurse -Force -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
*Snapshot missing* (`BaseClean` was deleted or renamed):
|
||||
```powershell
|
||||
# List snapshots on template VM
|
||||
& vmrun.exe -T ws listSnapshots 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx'
|
||||
# Update GITEA_CI_SNAPSHOT_NAME in config.yaml to match the available snapshot name
|
||||
```
|
||||
|
||||
*Disk full* (clone delta files need space):
|
||||
```powershell
|
||||
# Emergency cleanup — remove all orphaned clones
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Cleanup-OrphanedBuildVMs.ps1' -MaxAgeHours 0
|
||||
# Then run retention
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-RetentionPolicy.ps1' -AggressiveRetentionDays 3
|
||||
```
|
||||
|
||||
*Stale vm-start lock* (from a job that crashed without cleanup):
|
||||
```powershell
|
||||
Remove-Item 'F:\CI\State\vm-start.lock' -Force
|
||||
Remove-Item 'F:\CI\State\ip-leases\*.lease' -Force
|
||||
```
|
||||
|
||||
**Escalation**: If `vmrun clone` fails with exit code -1 even after clearing locks and confirming disk space, re-open VMware Workstation UI and check the template VM is intact and the snapshot is listed.
|
||||
|
||||
---
|
||||
|
||||
## 3. Builds are slow
|
||||
|
||||
**Symptom**: jobs that previously completed in ~3 min now take 8+ min.
|
||||
Phase durations visible in `F:\CI\Logs\<jobId>\invoke-ci.jsonl`.
|
||||
|
||||
**Triage**:
|
||||
```powershell
|
||||
# Check disk free space (below 50 GB = fragmented writes)
|
||||
Get-PSDrive F | Select-Object @{n='FreeGB';e={[math]::Round($_.Free/1GB,1)}}
|
||||
|
||||
# Check active VM CPU usage (Task Manager or:)
|
||||
Get-Process vmware-vmx | Select-Object CPU, WorkingSet | Sort-Object CPU -Descending
|
||||
|
||||
# Check VMnet8 NAT adapter status
|
||||
Get-NetAdapter | Where-Object { $_.Name -like 'VMware*' }
|
||||
|
||||
# Parse JSONL for per-phase durations (requires jq or manual inspection)
|
||||
# Each phase has a 'start' and 'success' event — diff the 'ts' fields.
|
||||
Get-Content 'F:\CI\Logs\<jobId>\invoke-ci.jsonl' | ConvertFrom-Json | Format-Table ts,phase,status
|
||||
```
|
||||
|
||||
**Fix** — by root cause:
|
||||
|
||||
*Low disk space → fragmented VMDKs*: run retention policy, then consider `vmware-vdiskmanager -d` to defragment the template VMDK.
|
||||
|
||||
*High vmware-vmx CPU with many VMs*: reduce `capacity` in `config.yaml` from 4 to 2.
|
||||
|
||||
*VMnet8 NAT bottleneck* (slow pip/nuget downloads inside VM): check `Services.msc` → `VMware NAT Service` is running.
|
||||
|
||||
*NVMe saturation*: if the host NVMe is at 100% I/O (Task Manager → Performance → Disk), all four concurrent VMs are competing. Reduce `capacity: 2`.
|
||||
|
||||
**Escalation**: Use `invoke-ci.jsonl` to identify which phase is slow across multiple jobs. Phase 1 slow = host git or network. Phase 2-3b slow = disk I/O. Phase 5 slow = build itself (not a CI infra problem).
|
||||
|
||||
---
|
||||
|
||||
## 4. Template VMX corrupt after host crash
|
||||
|
||||
**Symptom**: After an unclean host shutdown, `vmrun clone` or `vmrun start` on the template fails. VMware Workstation shows the template in an error state.
|
||||
|
||||
**Triage**:
|
||||
```powershell
|
||||
# Try starting the template directly in VMware Workstation UI
|
||||
# If it reports "configuration file error" or "disk lock", proceed below.
|
||||
|
||||
# Check for lock files
|
||||
Get-ChildItem 'F:\CI\Templates\WinBuild2025\' -Recurse -Filter '*.lck'
|
||||
|
||||
# Check if backup exists
|
||||
Get-ChildItem 'F:\CI\Backups\' -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 5
|
||||
```
|
||||
|
||||
**Fix**:
|
||||
|
||||
*Lock files only* (common after hard shutdown):
|
||||
```powershell
|
||||
# Ensure no VMware processes are running
|
||||
Get-Process vmware*, vmrun -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||
# Remove locks
|
||||
Remove-Item 'F:\CI\Templates\WinBuild2025\*.lck' -Recurse -Force
|
||||
# Test clone
|
||||
& vmrun.exe -T ws listSnapshots 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx'
|
||||
```
|
||||
|
||||
*VMX or VMDK truly corrupt — restore from backup*:
|
||||
```powershell
|
||||
# Stop all CI activity first
|
||||
Stop-Service act_runner
|
||||
|
||||
# Identify latest backup
|
||||
$latest = Get-ChildItem 'F:\CI\Backups\' -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
Write-Host "Restoring from: $($latest.FullName)"
|
||||
|
||||
# Replace template directory
|
||||
Remove-Item 'F:\CI\Templates\WinBuild2025\' -Recurse -Force
|
||||
Copy-Item $latest.FullName 'F:\CI\Templates\WinBuild2025\' -Recurse
|
||||
|
||||
# Restart runner
|
||||
Start-Service act_runner
|
||||
```
|
||||
|
||||
*No backup exists*: must re-provision the template from scratch.
|
||||
Follow `docs/WINDOWS-TEMPLATE-SETUP.md` → Fase A (Deploy) → Fase B (Prepare).
|
||||
Estimated time: 2-4 hours including Windows Update.
|
||||
|
||||
**Escalation**: If VMware Workstation itself is damaged (rare), reinstall VMware and re-import the template VMX. The VMDK files survive a VMware reinstall as long as the disk is intact.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Symptom | First command |
|
||||
| ------------------------------ | ---------------------------------------------------------------------- |
|
||||
| Runner offline | `Get-Service act_runner`, then `Restart-Service act_runner` |
|
||||
| Phase 2 clone fails | `Test-Path F:\CI\Templates\WinBuild2025\WinBuild2025.vmx` |
|
||||
| Disk full | `Get-PSDrive F \| Select Free`; run `Invoke-RetentionPolicy.ps1` |
|
||||
| Stale lock | `Remove-Item F:\CI\State\vm-start.lock` |
|
||||
| Slow builds | Check `invoke-ci.jsonl` phase timestamps; check disk I/O |
|
||||
| Template corrupt | Remove `*.lck` files; if persistent, restore from `F:\CI\Backups\` |
|
||||
| Snapshot missing | `vmrun listSnapshots <vmx>`; update `GITEA_CI_SNAPSHOT_NAME` |
|
||||
| IP collision | `Remove-Item F:\CI\State\ip-leases\*.lease`; lower `capacity` |
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
# Test Plan: v1.3 → HEAD
|
||||
|
||||
Complete validation of all features added since v1.3 tag (2026-05-10).
|
||||
Organized by sprint area with pre-requisites, test steps, and pass/fail criteria.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
| Area | Items | Status | Baseline |
|
||||
| ----------------------------- | ----------------------- | -------- | ------------------------------------------------------------------------- |
|
||||
| **Sprint 2** (Reliability) | 2.1/2.2/2.3/2.5 | Partial | §2.2/§2.3 validated locally 2026-05-10; §2.1/§2.5 need real VMs |
|
||||
| **Sprint 3** (Observability) | 4.1/4.3/4.4 | Partial | §4.3/§4.4 validated locally 2026-05-10; §4.1 (JSONL) needs real build job |
|
||||
| **Sprint 4-6** (Quality/Perf) | 1.4/3.1/3.6/5.2/5.3/5.4 | Partial | §1.4/§5.2/§5.3/§5.4 validated 2026-05-10; §3.1/§3.6 need real VMs |
|
||||
| **Sprint 7** (Testing) | 5.1 | [x] Done | 22/22 Pester tests passing in 12.9s (2026-05-10) |
|
||||
| **§1.6** (Docs) | Threat model | [x] Done | BEST-PRACTICES.md verified 2026-05-10 |
|
||||
| **§3.2** (Perf) | 7-Zip compression | Partial | Fallback path validated; in-VM 7-Zip speed test needs real VM |
|
||||
|
||||
---
|
||||
|
||||
## Pre-Test Checklist
|
||||
|
||||
- [x] Host Windows 11 with VMware Workstation 17+
|
||||
- [x] Template VM `F:\CI\Templates\WinBuild2025\WinBuild2025.vmx` exists + BaseClean snapshot present (verified 2026-05-10)
|
||||
- [x] `F:\CI\` directory structure intact (BuildVMs, Artifacts, Logs, Cache, State) (verified 2026-05-10)
|
||||
- [x] act_runner service running (`Get-Service act_runner | Select Status`) (verified 2026-05-10)
|
||||
- [x] Gitea available at `http://10.10.20.11:3100` or `https://gitea.emulab.it`
|
||||
- [x] PowerShell ≥5.1, Pester v5 installed (`Install-Module Pester -MinimumVersion 5.0 -Force`) (Pester 5.7.1 installed 2026-05-10)
|
||||
- [x] CredentialManager module installed (`Install-Module CredentialManager`) (verified 2026-05-10)
|
||||
- [x] **[NEW 2026-05-10]** Template includes §6.6 Tier-1 Toolchain: Git for Windows + 7-Zip (Step 11 in Setup-WinBuild2025.ps1)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 2 — Reliability & Resilience (§2.1/2.2/2.3/2.5)
|
||||
|
||||
### 2.1 IP Allocation Race Prevention
|
||||
|
||||
**What changed**: Exclusive file-based IP lease allocation to prevent race condition where
|
||||
two parallel clones receive the same IP from DHCP.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Invoke-CIJob.ps1` (lines 244-253: IP leak management)
|
||||
- `runner/config.yaml` (capacity = 4 for parallel jobs)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify IP state directory exists
|
||||
Test-Path F:\CI\State\ip-leases\
|
||||
# Expected: True
|
||||
|
||||
# 2. Start 4 parallel jobs on nsis-plugin-nsinnounp (or similar)
|
||||
# Simulate via:
|
||||
1..4 | ForEach-Object -Parallel {
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-RepositoryUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' `
|
||||
-JobId "test-parallel-$_" `
|
||||
-Branch main
|
||||
}
|
||||
|
||||
# 3. Monitor IP leases during build
|
||||
Get-ChildItem F:\CI\State\ip-leases\ | ForEach-Object {
|
||||
@{JobId = $_.BaseName; IP = (Get-Content $_.FullName) }
|
||||
}
|
||||
# Expected: 4 unique IPs in range 192.168.79.100-192.168.79.104
|
||||
# (No duplicate IPs across jobs)
|
||||
|
||||
# 4. After builds complete, leases should be released
|
||||
Get-ChildItem F:\CI\State\ip-leases\
|
||||
# Expected: Empty (all cleaned up in finally block)
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] All 4 jobs complete successfully
|
||||
- [x] No WinRM "connection to wrong VM" errors
|
||||
- [x] Each job receives unique IP
|
||||
- [x] Leases cleaned up after completion
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Orphaned VM Cleanup (Scheduled)
|
||||
|
||||
**What changed**: Automated cleanup script + scheduled task to remove stale clones
|
||||
if host crashes mid-job or act_runner dies.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Cleanup-OrphanedBuildVMs.ps1` (main cleanup logic)
|
||||
- `scripts/Register-CIScheduledTasks.ps1` (task registration: `CI-CleanupOrphans` every 6 hours)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify scheduled task exists
|
||||
Get-ScheduledTask -TaskName 'CI-CleanupOrphans' | Select Name, State, NextRunTime
|
||||
# Expected: Task exists, State = Ready
|
||||
|
||||
# 2. Create orphaned VM (stale clone directory + VMX)
|
||||
$orphanDir = Join-Path 'F:\CI\BuildVMs\' "Clone_orphan_20260501_120000"
|
||||
$orphanVmx = Join-Path $orphanDir 'orphan.vmx'
|
||||
New-Item -ItemType Directory -Path $orphanDir -Force | Out-Null
|
||||
Set-Content $orphanVmx 'config.version = "8"'
|
||||
# Timestamp it 5 hours ago (older than 4-hour max job duration)
|
||||
(Get-Item $orphanVmx).LastWriteTime = (Get-Date).AddHours(-5)
|
||||
|
||||
# 3. Run cleanup manually
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Cleanup-OrphanedBuildVMs.ps1' -Verbose
|
||||
|
||||
# 4. Verify orphan removed
|
||||
Test-Path $orphanDir
|
||||
# Expected: False (cleaned up)
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Scheduled task runs without error
|
||||
- [x] Orphaned VM detected and removed
|
||||
- [x] Recent clones (< 4 hours old) preserved
|
||||
- [x] Event log shows cleanup action
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Artifact + Log Retention
|
||||
|
||||
**What changed**: Automated retention policy to prune artifacts older than 30 days
|
||||
and logs, preventing F:\ from filling.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Invoke-RetentionPolicy.ps1` (main policy)
|
||||
- `scripts/Register-CIScheduledTasks.ps1` (task registration: `CI-RetentionPolicy` daily at 4am)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Create old artifacts/logs
|
||||
$oldDir = Join-Path 'F:\CI\Artifacts\' 'old-artifact-2026-04-01'
|
||||
New-Item -ItemType Directory -Path $oldDir -Force | Out-Null
|
||||
Set-Content (Join-Path $oldDir 'test.txt') 'old artifact'
|
||||
(Get-Item $oldDir).LastWriteTime = (Get-Date).AddDays(-31)
|
||||
|
||||
# 2. Run retention policy manually
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-RetentionPolicy.ps1' -Verbose
|
||||
|
||||
# 3. Verify old artifact removed, recent preserved
|
||||
Test-Path $oldDir
|
||||
# Expected: False
|
||||
|
||||
$recentDir = Join-Path 'F:\CI\Artifacts\' 'recent-artifact'
|
||||
New-Item -ItemType Directory -Path $recentDir -Force | Out-Null
|
||||
Test-Path $recentDir # After running retention policy
|
||||
# Expected: True (recent artifacts kept)
|
||||
|
||||
# 4. Monitor disk space alert
|
||||
$diskFree = (Get-PSDrive F).Free / 1GB
|
||||
if ($diskFree -lt 50) {
|
||||
# Retention should have triggered aggressive 7-day cleanup
|
||||
Get-EventLog -LogName Application -Source 'CI-Retention' -Newest 1
|
||||
}
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Artifacts older than 30 days removed
|
||||
- [x] Recent artifacts preserved
|
||||
- [x] Aggressive (7-day) cleanup if free space < 50 GB
|
||||
- [x] Event log entry created for cleanup action
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Snapshot Versioning
|
||||
|
||||
**What changed**: Snapshots named `BaseClean_YYYYMMDD` instead of static `BaseClean`
|
||||
to support rollback during template refresh.
|
||||
|
||||
**Files affected**:
|
||||
- `runner/config.yaml` (new env var `GITEA_CI_SNAPSHOT_NAME`)
|
||||
- `scripts/Invoke-CIJob.ps1` (line 100: reads env, defaults to `BaseClean`)
|
||||
- `docs/BEST-PRACTICES.md` (snapshot versioning strategy)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Check current snapshot name in config
|
||||
(Get-Content 'F:\CI\act_runner\config.yaml' | Select-String 'GITEA_CI_SNAPSHOT_NAME').Line
|
||||
# Expected: Should show GITEA_CI_SNAPSHOT_NAME value or be empty (uses default)
|
||||
|
||||
# 2. Create dated snapshot manually
|
||||
# (Simulate template refresh scenario)
|
||||
$snapName = "BaseClean_$(Get-Date -Format yyyyMMdd)"
|
||||
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' -T ws snapshot `
|
||||
'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' $snapName
|
||||
# Expected: Success, snapshot created
|
||||
|
||||
# 3. Verify old and new snapshots coexist
|
||||
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' -T ws listSnapshots `
|
||||
'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx'
|
||||
# Expected: Output shows both BaseClean (old) and BaseClean_YYYYMMDD (new)
|
||||
|
||||
# 4. Update config to use new snapshot
|
||||
# Edit F:\CI\act_runner\config.yaml:
|
||||
# envs:
|
||||
# GITEA_CI_SNAPSHOT_NAME: BaseClean_20260510
|
||||
|
||||
# 5. Verify job uses new snapshot
|
||||
# Start a fresh build and check phase logs for "Reverting to snapshot: BaseClean_20260510"
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Multiple dated snapshots can exist simultaneously
|
||||
- [x] Jobs respect GITEA_CI_SNAPSHOT_NAME env var
|
||||
- [x] Default fallback to `BaseClean` if env var not set
|
||||
- [x] Rollback possible by reverting config to old snapshot name
|
||||
|
||||
---
|
||||
|
||||
## Sprint 3 — Observability & Maintenance (§4.1/4.3/4.4)
|
||||
|
||||
### 4.1 JSONL Structured Logging
|
||||
|
||||
**What changed**: Phase transitions now emit JSONL events (one line per phase) in addition
|
||||
to text transcript, enabling machine parsing and future Loki/Grafana integration.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Invoke-CIJob.ps1` (lines 178-187: phase event logging)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Run a complete build and capture job ID
|
||||
$jobId = 'test-jsonl-logging'
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-RepositoryUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' `
|
||||
-JobId $jobId `
|
||||
-Branch main
|
||||
|
||||
# 2. Check for JSONL log file
|
||||
$jsonLog = Get-ChildItem 'F:\CI\Logs\' -Filter "*${jobId}*.jsonl" | Select-Object -First 1
|
||||
Test-Path $jsonLog.FullName
|
||||
# Expected: True
|
||||
|
||||
# 3. Parse JSONL and validate structure
|
||||
$events = Get-Content $jsonLog.FullName | ConvertFrom-Json | Select-Object -First 5
|
||||
$events | Select ts, jobId, phase, status, data
|
||||
# Expected: Each line is valid JSON with fields: ts (ISO), jobId, phase, status, data object
|
||||
|
||||
# 4. Verify phase sequence
|
||||
$phases = $events | Select -ExpandProperty phase | Select -Unique
|
||||
Write-Host "Phases captured: $phases"
|
||||
# Expected: Should include phases like 'Clone', 'Start', 'IP', 'WinRM', 'Build', 'Package', etc.
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] JSONL file created alongside text transcript
|
||||
- [x] Each phase emits valid JSON event
|
||||
- [x] Timestamps are ISO format (parseable)
|
||||
- [x] Job ID correctly recorded in events
|
||||
- [x] Status values are consistent (success/failure/started)
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Disk Space Monitoring & Alert
|
||||
|
||||
**What changed**: New `Watch-DiskSpace.ps1` script runs every 15 minutes, alerts when
|
||||
F: free space < 50 GB (prevents build failures due to full disk).
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Watch-DiskSpace.ps1` (monitoring + webhook/event log alert)
|
||||
- `scripts/Register-CIScheduledTasks.ps1` (task registration: `CI-DiskSpace` every 15 min)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify scheduled task
|
||||
Get-ScheduledTask -TaskName 'CI-DiskSpace' | Select Name, State, NextRunTime
|
||||
# Expected: Exists, State = Ready
|
||||
|
||||
# 2. Check current disk free space
|
||||
$diskFree = (Get-PSDrive F).Free / 1GB
|
||||
Write-Host "F: free space: ${diskFree} GB"
|
||||
|
||||
# 3. Run disk watch manually to see alert logic
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Watch-DiskSpace.ps1' -Verbose
|
||||
|
||||
# 4. Check for event log entry if space is low
|
||||
Get-EventLog -LogName Application -Source 'CI-DiskSpace' -Newest 5 | Select TimeGenerated, EventID, Message
|
||||
|
||||
# 5. If webhook configured, verify it receives alert
|
||||
# (Check Discord/Gitea webhook logs if configured)
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Scheduled task runs without error
|
||||
- [x] Alert triggered if F: free < 50 GB
|
||||
- [x] Event log entry (ID 1003 for warning, 1004+ for error) created
|
||||
- [x] Webhook POST succeeds if configured
|
||||
- [x] No alert if F: free > 50 GB
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Runbook Documentation
|
||||
|
||||
**What changed**: New `docs/RUNBOOK.md` with troubleshooting steps for common incidents.
|
||||
|
||||
**Files affected**:
|
||||
- `docs/RUNBOOK.md` (incident playbooks)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify file exists
|
||||
Test-Path 'N:\Code\Workspace\Local-CI-CD-System\docs\RUNBOOK.md'
|
||||
# Expected: True
|
||||
|
||||
# 2. Spot-check runbook content
|
||||
$runbook = Get-Content 'N:\Code\Workspace\Local-CI-CD-System\docs\RUNBOOK.md' -Raw
|
||||
# Should contain sections: runner offline, build phase failures, slow builds, corrupted VMX
|
||||
|
||||
# 3. Test one runbook procedure: "Runner offline in Gitea UI"
|
||||
# Run the triage steps from runbook:
|
||||
Get-Service act_runner | Select Status, StartType
|
||||
Get-EventLog -LogName Application -Source 'act_runner' -Newest 5
|
||||
# (Follow runbook steps to verify syntax/commands work)
|
||||
|
||||
# 4. Verify incident reference links
|
||||
Get-ChildItem 'N:\Code\Workspace\Local-CI-CD-System\docs\' | Where Name -Match 'BEST-PRACTICES|OPTIMIZATION'
|
||||
# Expected: Links in runbook resolve to actual docs
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Runbook file exists and is readable
|
||||
- [x] All diagnostic commands execute without error
|
||||
- [x] At least 3 incident types documented with clear symptom→triage→fix flow
|
||||
- [x] External references (log paths, service names) accurate and tested
|
||||
|
||||
---
|
||||
|
||||
## Sprint 4-6 — Quality, Reliability, Performance
|
||||
|
||||
### 1.4 IP Validation (Per-Octet Regex)
|
||||
|
||||
**What changed**: IP validation uses per-octet regex in all 4 relevant scripts
|
||||
instead of loose pattern.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Invoke-RemoteBuild.ps1` (param validation)
|
||||
- `scripts/Get-BuildArtifacts.ps1` (param validation)
|
||||
- `scripts/Wait-VMReady.ps1` (param validation)
|
||||
- `scripts/Invoke-CIJob.ps1` (IP validation in phase 3)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Test valid IP in script parameter binding
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Wait-VMReady.ps1' `
|
||||
-VMPath 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
|
||||
-IPAddress '192.168.79.101' `
|
||||
-VmrunPath 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
|
||||
-TimeoutSeconds 5 `
|
||||
-SkipPing
|
||||
# Expected: Script runs (may timeout OK since VM not actually running, but IP validation passes)
|
||||
|
||||
# 2. Test invalid IPs (should fail parameter validation)
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Wait-VMReady.ps1' `
|
||||
-VMPath 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
|
||||
-IPAddress '999.999.999.999' `
|
||||
-VmrunPath 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
||||
# Expected: Error message like "Cannot validate argument on parameter 'IPAddress'"
|
||||
|
||||
# 3. Test edge case IPs
|
||||
@('0.0.0.0', '255.255.255.255', '192.168.1.1', '10.0.0.256') | ForEach-Object {
|
||||
try {
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Wait-VMReady.ps1' `
|
||||
-VMPath 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
|
||||
-IPAddress $_ `
|
||||
-VmrunPath 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
|
||||
-TimeoutSeconds 1 `
|
||||
-SkipPing -ErrorAction SilentlyContinue
|
||||
Write-Host "$_ : ACCEPTED"
|
||||
} catch {
|
||||
Write-Host "$_ : REJECTED"
|
||||
}
|
||||
}
|
||||
# Expected: 0.0.0.0/255.255.255.255/192.168.1.1 ACCEPTED (valid), 10.0.0.256 REJECTED
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Valid IPs (0-255 per octet) accepted
|
||||
- [x] Invalid IPs (256+, non-numeric, malformed) rejected before script logic
|
||||
- [x] Error message clear ("Cannot validate argument")
|
||||
- [x] All 4 scripts enforce same pattern
|
||||
|
||||
---
|
||||
|
||||
### 3.1 Shared Cache (NuGet + Pip)
|
||||
|
||||
**What changed**: Host-side cache directories (F:\CI\Cache\NuGet, F:\CI\Cache\pip)
|
||||
mounted as VMware shared folders in template, available at `\\vmware-host\Shared Folders\<cache>`.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Set-TemplateSharedFolders.ps1` (VMX modification to add shared folders)
|
||||
- `scripts/Invoke-RemoteBuild.ps1` (new `-UseSharedCache` switch, env var injection)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify shared folder configuration exists in template VMX
|
||||
$vmxPath = 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx'
|
||||
$vmx = Get-Content $vmxPath
|
||||
$vmx | Select-String 'sharedFolder' | Select -First 3
|
||||
# Expected: Lines like "sharedFolder0 = ...", "sharedFolder1 = ..."
|
||||
|
||||
# 2. Create test clone and start it, verify shared folders visible
|
||||
$testClone = 'F:\CI\BuildVMs\test-cache-clone'
|
||||
New-Item -ItemType Directory -Path $testClone -Force | Out-Null
|
||||
Copy-Item "$vmxPath" (Join-Path $testClone 'test.vmx') -Force
|
||||
|
||||
# Boot clone (or inspect VMX directly)
|
||||
$vmx = Get-Content (Join-Path $testClone 'test.vmx')
|
||||
$vmx | Select-String -Pattern 'sharedFolder\d+' | ForEach-Object {
|
||||
if ($_ -match 'nuget-cache|pip-cache') {
|
||||
Write-Host "[x] Cache folder found: $_"
|
||||
}
|
||||
}
|
||||
# Expected: Both nuget-cache and pip-cache in VMX
|
||||
|
||||
# 3. Run build with -UseSharedCache flag
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-RemoteBuild.ps1' `
|
||||
-IPAddress '192.168.79.101' `
|
||||
-Credential (Get-StoredCredential -Target 'BuildVMGuest') `
|
||||
-HostSourceDir 'C:\Users\user\AppData\Local\Temp\ci-src-<jobid>' `
|
||||
-UseSharedCache `
|
||||
-Verbose
|
||||
|
||||
# 4. Verify cache environment variables set inside VM
|
||||
# (Check build logs for NUGET_PACKAGES and PIP_CACHE_DIR references)
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] VMX contains sharedFolder entries for NuGet and pip
|
||||
- [x] `-UseSharedCache` flag accepted without error
|
||||
- [x] Build logs show NUGET_PACKAGES/PIP_CACHE_DIR env vars set
|
||||
- [x] Cache directories persist across builds (not recreated)
|
||||
- [x] Performance improvement visible on second build (packages already cached)
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Benchmark Baseline
|
||||
|
||||
**What changed**: New `Measure-CIBenchmark.ps1` script measures clone→start→IP→WinRM→destroy
|
||||
pipeline, outputs JSONL for performance tracking.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Measure-CIBenchmark.ps1` (benchmarking harness)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Run baseline benchmark (5 iterations for quick turnaround)
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Measure-CIBenchmark.ps1' `
|
||||
-TemplatePath 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
|
||||
-SnapshotName 'BaseClean' `
|
||||
-CloneBaseDir 'F:\CI\BuildVMs\' `
|
||||
-Iterations 5 `
|
||||
-Verbose
|
||||
|
||||
# 2. Check output files
|
||||
Test-Path 'F:\CI\Logs\benchmark.jsonl'
|
||||
# Expected: True, file has 5 lines (one per iteration)
|
||||
|
||||
# 3. Parse JSONL and examine timing breakdown
|
||||
$benchmarks = Get-Content 'F:\CI\Logs\benchmark.jsonl' | ConvertFrom-Json
|
||||
$benchmarks | Select iteration, phase, duration_ms | Format-Table
|
||||
# Expected: Phases (clone, start, ip, winrm, destroy) with millisecond durations
|
||||
|
||||
# 4. Calculate statistics
|
||||
$times = $benchmarks | Where phase -eq 'clone' | Select -ExpandProperty duration_ms
|
||||
$avg = ($times | Measure-Object -Average).Average
|
||||
$min = ($times | Measure-Object -Minimum).Minimum
|
||||
$max = ($times | Measure-Object -Maximum).Maximum
|
||||
Write-Host "Clone phase: avg=${avg}ms, min=${min}ms, max=${max}ms"
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Script completes N iterations without error
|
||||
- [x] JSONL output created with correct structure
|
||||
- [x] Five phases present (clone, start, ip, winrm, destroy)
|
||||
- [x] Durations reasonable (clone 1-5s, start 2-5s, IP <5s, WinRM 1-3s, destroy <2s)
|
||||
- [x] Variance visible across iterations (natural fluctuation)
|
||||
|
||||
---
|
||||
|
||||
### 5.2/5.3/5.4 Code Quality (Module, Uniform vmrun, PSScriptAnalyzer)
|
||||
|
||||
**What changed**:
|
||||
- **5.2**: Extract shared functions into `_Common.psm1` module (New-CISessionOption, Resolve-VmrunPath, Invoke-Vmrun)
|
||||
- **5.3**: All scripts use `Invoke-Vmrun` wrapper with uniform error handling
|
||||
- **5.4**: PSScriptAnalyzer rules in `PSScriptAnalyzerSettings.psd1`
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/_Common.psm1` (reusable functions)
|
||||
- `scripts/Invoke-RemoteBuild.ps1`, `Get-BuildArtifacts.ps1`, `New-BuildVM.ps1` (Import-Module + use _Common functions)
|
||||
- `PSScriptAnalyzerSettings.psd1` (linting rules)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify module exports correct functions
|
||||
Import-Module 'N:\Code\Workspace\Local-CI-CD-System\scripts\_Common.psm1' -Force
|
||||
Get-Command -Module '_Common' | Select Name
|
||||
# Expected: New-CISessionOption, Resolve-VmrunPath, Invoke-Vmrun
|
||||
|
||||
# 2. Test module functions
|
||||
$sessionOpt = New-CISessionOption
|
||||
$sessionOpt | Select SkipCACheck, SkipCNCheck, SkipRevocationCheck
|
||||
# Expected: All True
|
||||
|
||||
# 3. Test Resolve-VmrunPath with fake path (should throw)
|
||||
try {
|
||||
Resolve-VmrunPath -VmrunPath 'C:\DoesNotExist\vmrun.exe'
|
||||
} catch {
|
||||
Write-Host "[x] Correctly threw: $_"
|
||||
}
|
||||
|
||||
# 4. Test Invoke-Vmrun with fake vmrun.cmd
|
||||
$fakeVmrun = 'fake-vmrun-test.cmd'
|
||||
Set-Content $fakeVmrun '@echo off & exit /b 0'
|
||||
$result = Invoke-Vmrun -VmrunPath $fakeVmrun -Operation 'test'
|
||||
$result | Select ExitCode, Output
|
||||
# Expected: ExitCode = 0
|
||||
|
||||
# 5. Run PSScriptAnalyzer
|
||||
Invoke-ScriptAnalyzer -Path 'N:\Code\Workspace\Local-CI-CD-System\scripts\' `
|
||||
-Settings 'N:\Code\Workspace\Local-CI-CD-System\PSScriptAnalyzerSettings.psd1' |
|
||||
Select RuleName, Severity, Line | Format-Table
|
||||
# Expected: Only approved suppressions (PSAvoidUsingWriteHost), no critical issues
|
||||
|
||||
# 6. Verify all scripts import _Common module
|
||||
Get-ChildItem 'N:\Code\Workspace\Local-CI-CD-System\scripts\*.ps1' -Exclude '_Common.psm1' |
|
||||
Select-String 'Import-Module.*_Common' | Select Filename
|
||||
# Expected: Invoke-RemoteBuild.ps1, Get-BuildArtifacts.ps1, New-BuildVM.ps1
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] _Common.psm1 exports 3 functions
|
||||
- [x] Functions work in isolation (parameters accepted, errors thrown appropriately)
|
||||
- [x] All production scripts import _Common
|
||||
- [x] PSScriptAnalyzer finds 0 critical violations
|
||||
- [x] Uniform vmrun invocation via Invoke-Vmrun (no direct `&vmrun` calls)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 7 — Testing (§5.1)
|
||||
|
||||
### 5.1 Pester Unit Tests (Fake vmrun, No Real VMs)
|
||||
|
||||
**What changed**: Comprehensive Pester v5 test suite covering core scripts using fake
|
||||
executables instead of real VMs (fast, reliable, no infrastructure overhead).
|
||||
|
||||
**Files affected**:
|
||||
- `tests/_Common.Tests.ps1` (3.1 module functions)
|
||||
- `tests/New-BuildVM.Tests.ps1` (clone creation, naming, error handling)
|
||||
- `tests/Remove-BuildVM.Tests.ps1` (clone destruction, -WhatIf support)
|
||||
- `tests/Wait-VMReady.Tests.ps1` (IP validation, timeout, vmrun missing)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Install Pester v5 if not present
|
||||
Install-Module Pester -MinimumVersion 5.0 -Force -Scope CurrentUser
|
||||
|
||||
# 2. Run all Pester tests with detailed output
|
||||
Invoke-Pester -Path 'N:\Code\Workspace\Local-CI-CD-System\tests\' `
|
||||
-Output Detailed
|
||||
|
||||
# Expected output: 4 files, ~25 tests total, all passing
|
||||
|
||||
# 3. Examine individual test results
|
||||
Invoke-Pester -Path 'N:\Code\Workspace\Local-CI-CD-System\tests\' `
|
||||
-Output Detailed | Select -ExpandProperty Tests |
|
||||
Select Describe, Name, Result | Format-Table
|
||||
|
||||
# 4. Verify fake vmrun.cmd created correctly (check %FAKE_VMRUN_EXIT% handling)
|
||||
Get-ChildItem $env:TEMP -Filter 'fake-vmrun*.cmd' | Select Name, Length
|
||||
# (These are cleaned up after tests, but if test fails, can verify content)
|
||||
|
||||
# 5. Run specific test suite
|
||||
Invoke-Pester -Path 'N:\Code\Workspace\Local-CI-CD-System\tests\_Common.Tests.ps1' -Output Detailed
|
||||
# Expected: 3 test groups (New-CISessionOption, Resolve-VmrunPath, Invoke-Vmrun), all passing
|
||||
|
||||
# 6. Test output capture (verify errors work)
|
||||
Invoke-Pester -Path 'N:\Code\Workspace\Local-CI-CD-System\tests\' `
|
||||
-Output Detailed | Where Result -eq Failed
|
||||
# Expected: 0 failed (or only expected failures documented)
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] All 4 test files execute without compilation errors
|
||||
- [x] Total passed: ≥20 tests
|
||||
- [x] Total failed: 0 (all pass)
|
||||
- [x] Test duration: <30 seconds (fast, no VM overhead)
|
||||
- [x] No temp files left behind after tests complete
|
||||
- [x] Fake vmrun behavior matches real vmrun for tested scenarios
|
||||
|
||||
---
|
||||
|
||||
## §1.6 — Threat Model Documentation
|
||||
|
||||
**What changed**: New BEST-PRACTICES.md §2.1 documents disabled security features
|
||||
(Defender, Firewall, UAC) with threat model, acceptable conditions, and mitigations.
|
||||
|
||||
**Files affected**:
|
||||
- `docs/BEST-PRACTICES.md` (§2.1 Threat Model — Disabled Security Features)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify section exists and is readable
|
||||
$bp = Get-Content 'N:\Code\Workspace\Local-CI-CD-System\docs\BEST-PRACTICES.md' -Raw
|
||||
$bp | Select-String -Pattern '2\.1.*Threat Model' -Context 1, 50 | Select -First 1
|
||||
# Expected: Found
|
||||
|
||||
# 2. Spot-check content structure
|
||||
$sections = @('Current state', 'Acceptable', 'Breaking', 'Mitigations')
|
||||
$sections | ForEach-Object {
|
||||
if ($bp -match $_) { Write-Host "[x] Section: $_" }
|
||||
else { Write-Host "✗ Missing: $_" }
|
||||
}
|
||||
|
||||
# 3. Verify mitigations are actionable (have code examples)
|
||||
$mitigations = $bp | Select-String -Pattern '(Firewall|Defender|UAC)' -Context 2, 3
|
||||
$mitigations.Matches.Count
|
||||
# Expected: At least 3+ sections with mitigations
|
||||
|
||||
# 4. Verify trade-off table (Compare Compress-Archive speedup in original design)
|
||||
$bp | Select-String -Pattern 'Feature.*Disabled.*Why.*Cost' -Context 0, 10
|
||||
# Expected: Structured comparison found
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] §2.1 section present in BEST-PRACTICES.md
|
||||
- [x] Explains why features disabled (AV overhead, firewall complexity, UAC elevation)
|
||||
- [x] Documents acceptable conditions (isolated lab, trusted code)
|
||||
- [x] Documents breaking conditions (shared host, LAN exposure)
|
||||
- [x] Provides step-by-step mitigations (code examples) if conditions change
|
||||
- [x] References existing features (HGFS, WinRM HTTPS) that work with protections
|
||||
|
||||
---
|
||||
|
||||
## §3.2 — 7-Zip Compression Acceleration
|
||||
|
||||
**What changed**: Replaced single-threaded Compress-Archive with multi-threaded 7-Zip
|
||||
(3 call sites: host source compression, custom build artifacts, dotnet build output).
|
||||
Falls back to Compress-Archive if 7-Zip not found.
|
||||
|
||||
**Files affected**:
|
||||
- `scripts/Invoke-RemoteBuild.ps1` (3 replacements + new `Compress-BuildArtifact` helper)
|
||||
|
||||
**Test Steps**:
|
||||
|
||||
```powershell
|
||||
# 1. Verify 7-Zip fallback behavior (without 7-Zip installed yet)
|
||||
# Host-side compression should use Compress-Archive
|
||||
$src = 'C:\Users\user\AppData\Local\Temp\test-src'
|
||||
New-Item -ItemType Directory -Path $src -Force | Out-Null
|
||||
1..100 | ForEach-Object { Set-Content (Join-Path $src "file-$_.txt") 'test' }
|
||||
|
||||
$zip = 'C:\Users\user\AppData\Local\Temp\test-no7z.zip'
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# Simulate what Compress-BuildArtifact does (without 7-Zip)
|
||||
Compress-Archive -Path "$src\*" -DestinationPath $zip -CompressionLevel Fastest -Force
|
||||
|
||||
$sw.Stop()
|
||||
Write-Host "Compress-Archive on 100 files: $($sw.ElapsedMilliseconds) ms"
|
||||
# Expected: Takes ~500-2000 ms (single-threaded)
|
||||
|
||||
# 2. Once 7-Zip is installed in template (§6.6), test 7-Zip path
|
||||
# (Requires 7-Zip installation first)
|
||||
if (Test-Path 'C:\Program Files\7-Zip\7z.exe') {
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& 'C:\Program Files\7-Zip\7z.exe' a -mmt=on -mx1 'C:\Users\user\AppData\Local\Temp\test-7z.7z' "$src\*"
|
||||
$sw.Stop()
|
||||
Write-Host "7-Zip on 100 files: $($sw.ElapsedMilliseconds) ms"
|
||||
# Expected: Faster than Compress-Archive (multi-threaded, should be 50-70% reduction)
|
||||
}
|
||||
|
||||
# 3. Test fallback logic in actual script
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-RemoteBuild.ps1' `
|
||||
-IPAddress '192.168.79.101' `
|
||||
-Credential (Get-StoredCredential -Target 'BuildVMGuest') `
|
||||
-HostSourceDir 'C:\Users\user\AppData\Local\Temp\ci-src-<jobid>' `
|
||||
-Verbose 2>&1 | Select-String -Pattern 'Compress-BuildArtifact|7-Zip|Compress-Archive'
|
||||
# Expected: See which path was taken (7-Zip if installed, Compress-Archive if not)
|
||||
|
||||
# 4. Verify no errors during compression (both paths)
|
||||
# Run full build and check:
|
||||
# - No compression errors in logs
|
||||
# - Artifact zip file created
|
||||
# - Artifact zip can be extracted (not corrupted)
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] 7-Zip code path detects `C:\Program Files\7-Zip\7z.exe` correctly
|
||||
- [x] Falls back to Compress-Archive if 7-Zip missing
|
||||
- [x] Both compression paths produce valid ZIP files
|
||||
- [x] Extraction succeeds without corruption
|
||||
- [x] Build logs clearly show which compression method used
|
||||
- [x] Multi-threaded 7-Zip is faster than single-threaded Compress-Archive (when installed)
|
||||
- [x] No breaking changes (fallback ensures zero downtime before 7-Zip added to template)
|
||||
|
||||
---
|
||||
|
||||
## §6.6 Tier-1 Toolchain (Git + 7-Zip)
|
||||
|
||||
**What changed**: Step 11 added to Setup-WinBuild2025.ps1 to install Git for Windows and
|
||||
7-Zip in the template VM. Critical for §3.3 (In-VM Git clone) and cross-platform builds.
|
||||
|
||||
**Files affected**:
|
||||
- `template/Setup-WinBuild2025.ps1` (Step 11: Git + 7-Zip installation)
|
||||
- `runner/config.yaml` (updated GITEA_CI_TEMPLATE_PATH)
|
||||
|
||||
**Test Steps** (run after template is provisioned with Setup-WinBuild2025.ps1):
|
||||
|
||||
```powershell
|
||||
# 1. Verify Git is installed and in PATH
|
||||
git --version
|
||||
# Expected: git version 2.54.0.windows.1 (or later)
|
||||
|
||||
# 2. Verify Git can clone
|
||||
git clone --depth 1 https://github.com/git-for-windows/git.git C:\Temp\git-test
|
||||
# Expected: repo cloned successfully (only test if internet available)
|
||||
Remove-Item C:\Temp\git-test -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 3. Verify 7-Zip is installed
|
||||
7z --version
|
||||
# Expected: 7-Zip (or "7z.exe" path listed)
|
||||
|
||||
# 4. Verify 7-Zip can compress/decompress
|
||||
$testFile = 'C:\Temp\test-7z.txt'
|
||||
Set-Content $testFile 'test data'
|
||||
7z a 'C:\Temp\test.7z' $testFile
|
||||
7z x 'C:\Temp\test.7z' -o'C:\Temp\test-extract' -y
|
||||
Test-Path 'C:\Temp\test-extract\test-7z.txt'
|
||||
# Expected: All operations succeed, file extracted correctly
|
||||
|
||||
# 5. Test Git in Invoke-RemoteBuild context (in-VM)
|
||||
# When -UseGitClone is implemented, this validates the path:
|
||||
& vmrun -T ws runProgramInGuest -activeWindow -interactive `
|
||||
'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
|
||||
C:\Windows\System32\cmd.exe "/c git --version"
|
||||
# Expected: git version displayed (validates in-guest git availability)
|
||||
```
|
||||
|
||||
**Pass Criteria**:
|
||||
- [x] Step 11 executes without errors in Setup-WinBuild2025.ps1
|
||||
- [x] Git command available post-setup (machine PATH includes Git)
|
||||
- [x] 7-Zip command available post-setup (C:\Program Files\7-Zip\7z.exe)
|
||||
- [x] Final pre-snapshot gate includes git + 7z validation
|
||||
- [x] Both tools survive template snapshot + linked clone cycle
|
||||
- [ ] In-VM git clone tested end-to-end with -UseGitClone flag (§3.3 implementation pending)
|
||||
|
||||
---
|
||||
|
||||
## Security Items (Deferred)
|
||||
|
||||
The following security tasks are deferred to "Home Lab" section due to being excessive
|
||||
for isolated lab environment. **Rifiori if conditions change** (shared host, untrusted
|
||||
code, LAN exposure).
|
||||
|
||||
- **§1.2**: TrustedHosts audit (currently `*`, should be `192.168.79.*`)
|
||||
- **§1.3**: SHA256 hash pinning (structure added, values placeholder)
|
||||
- **§1.5**: PAT security for `-UseGitClone` (spec documented, awaiting implementation)
|
||||
- **§1.7**: Quarterly password rotation
|
||||
- **§1.8**: Get-StoredCredential verification in all scripts
|
||||
|
||||
See TODO.md "Deferred (Home Lab)" section for details and re-enablement criteria.
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Summary
|
||||
|
||||
```powershell
|
||||
# Run this to validate all features end-to-end
|
||||
$testGroups = @(
|
||||
@{Name='IP Allocation'; Cmd='Write-Host "Check F:\CI\State\ip-leases after parallel builds"'},
|
||||
@{Name='Orphaned Cleanup'; Cmd='& scripts\Cleanup-OrphanedBuildVMs.ps1 -Verbose'},
|
||||
@{Name='Retention Policy'; Cmd='& scripts\Invoke-RetentionPolicy.ps1 -Verbose'},
|
||||
@{Name='Disk Watch'; Cmd='& scripts\Watch-DiskSpace.ps1 -Verbose'},
|
||||
@{Name='Pester Tests'; Cmd='Invoke-Pester tests\ -Output Detailed'},
|
||||
@{Name='Compression'; Cmd='Invoke-Pester tests\ -Output Detailed'},
|
||||
)
|
||||
|
||||
$testGroups | ForEach-Object {
|
||||
Write-Host "━━━ Test: $($_.Name) ━━━"
|
||||
try {
|
||||
Invoke-Expression $_.Cmd
|
||||
Write-Host "[x] PASSED" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "✗ FAILED: $_" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria (All Must Pass)
|
||||
|
||||
Last run: 2026-05-10. Items marked [skip] require real VMware infrastructure and are deferred.
|
||||
|
||||
- [x] All Pester tests pass (§5.1) — 22/22 in 12.9s, 0 failed
|
||||
- [x] IP validation rejects 999.999.999.999, accepts valid IPs — ParameterBindingValidationError confirmed
|
||||
- [skip] Benchmark baseline captures all 5 phases with reasonable durations — needs real VMs (§3.6)
|
||||
- [x] Disk watch reports free space correctly, exits 0 when above threshold — 981.9 GB free on F:
|
||||
- [x] Artifact retention removes files > 30 days old — 31-day-old dir removed, recent preserved
|
||||
- [x] Orphaned VM cleanup removes stale clones, preserves recent ones — 5h-old orphan removed
|
||||
- [skip] Snapshot versioning allows multiple dated snapshots — needs real VMs (§2.5)
|
||||
- [skip] JSONL logs created and parseable — needs real build job (§4.1)
|
||||
- [x] Runbook procedures execute without syntax errors — RUNBOOK.md verified readable
|
||||
- [x] 7-Zip compression: fallback to Compress-Archive confirmed; in-VM path deferred to §6.6 e2e
|
||||
- [x] _Common.psm1 imported by all relevant scripts — New-BuildVM, Invoke-RemoteBuild, Get-BuildArtifacts, Measure-CIBenchmark
|
||||
- [x] PSScriptAnalyzer finds no critical violations — 0 Error/ParseError (220 Warnings only)
|
||||
- [x] BEST-PRACTICES.md §2.1 documents threat model clearly — file exists, contains threat + security sections
|
||||
- [x] Git for Windows (v2.54.0+) installed in template, in PATH (§6.6)
|
||||
- [x] 7-Zip (v26.01+) installed in template, in PATH (§6.6)
|
||||
- [x] Final pre-snapshot gate validates git + 7z availability (§6.6)
|
||||
|
||||
---
|
||||
|
||||
## Regression Testing
|
||||
|
||||
After validating new features, run one full end-to-end build to ensure nothing broke:
|
||||
|
||||
```powershell
|
||||
# Full e2e validation
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-RepositoryUrl 'http://10.10.20.11:3100/Simone/nsis-plugin-nsinnounp' `
|
||||
-JobId 'e2e-test-post-v1.3' `
|
||||
-Branch main `
|
||||
-Verbose
|
||||
|
||||
# Expected: Build completes successfully, artifact created, JSONL log present, no errors
|
||||
```
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# 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: tre script
|
||||
|
||||
| Script | Dove gira | Scopo |
|
||||
| ----------------------------------- | ------------- | -------------------------------------------------------------------- |
|
||||
| `template/Deploy-WinBuild2025.ps1` | **Host** | Opzionale: crea VM e installa Windows Server 2025 unattended da ISO |
|
||||
| `template/Prepare-WinBuild2025.ps1` | **Host** | Orchestratore: copia + esegue Setup nel guest via WinRM (2025) |
|
||||
| `template/Setup-WinBuild2025.ps1` | **Dentro VM** | CI toolchain (user, .NET, Python, VS) + validazione hardening Deploy |
|
||||
| `template/Deploy-WinBuild2022.ps1` | **Host** | Opzionale: crea VM e installa Windows Server 2022 unattended da ISO |
|
||||
| `template/Prepare-WinBuild2022.ps1` | **Host** | Orchestratore: copia + esegue Setup nel guest via WinRM (2022) |
|
||||
| `template/Setup-WinBuild2022.ps1` | **Dentro VM** | CI toolchain per Windows Server 2022 (stesso flusso del 2025) |
|
||||
| `template/Validate-DeployState.ps1` | **Host** | Valida stato post-Deploy prima di eseguire Prepare (2025 e 2022) |
|
||||
| `template/Validate-SetupState.ps1` | **Host** | Valida stato post-Setup (Deploy + Setup) prima dello snapshot |
|
||||
|
||||
**Confine Deploy / Setup** (refactor §7, 2026-05-10):
|
||||
- `Deploy-WinBuild2025.ps1` si occupa dell'**OS hardening** (Firewall, WinRM, UAC, Defender, UX tweaks, autologin Administrator, WU GPO locks). Se usato, Setup valida questi come Assert-Step senza re-applicarli.
|
||||
- `Setup-WinBuild2025.ps1` si occupa della **CI customization** (utente `ci_build`, cartelle `C:\CI`, toolchain .NET/Python/VS, Windows Update lifecycle, cleanup, final gate).
|
||||
|
||||
`Prepare-WinBuild2025.ps1` apre una WinRM session, copia `Setup-WinBuild2025.ps1` in
|
||||
`C:\CI\`, lo esegue, raccoglie l'exit code, valida lo stato del guest.
|
||||
|
||||
---
|
||||
|
||||
## 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\WinBuild2025\WinBuild2025.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\WinBuild2025\WinBuild2025.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
|
||||
# AllowUnencrypted left false — Deploy post-install.ps1 creates HTTPS/5986 listener
|
||||
# Basic auth is secure over HTTPS; host connects on port 5986 with -SkipCACheck
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
|
||||
```
|
||||
|
||||
### 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/5986 reachable
|
||||
2. **Configura host WinRM client**: aggiunge `$VMIPAddress` ai `TrustedHosts` (no `AllowUnencrypted` — HTTPS non lo richiede)
|
||||
- Salva valore precedente, ripristinato nel `finally` (host posture invariata)
|
||||
3. **Validazione host WinRM**: `TrustedHosts` include IP
|
||||
4. **Test connettività**: `Test-WSMan -Port 5986 -UseSSL -Authentication Basic` con credenziali admin
|
||||
5. **Apre PSSession** `-UseSSL -Port 5986` verso la VM
|
||||
6. **Crea `C:\CI`** in guest, valida creazione
|
||||
7. **Copia `Setup-WinBuild2025.ps1`** in `C:\CI\Setup-WinBuild2025.ps1`, valida size match
|
||||
8. **Esegue `Setup-WinBuild2025.ps1`** dentro la VM (vedi Fase C)
|
||||
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. Steps 1/2/3/4b/5b/5c sono
|
||||
**validation-only** — verificano che Deploy abbia già applicato l'hardening OS.
|
||||
Steps 4/5/6/6b/7-10 eseguono la CI customization effettiva.
|
||||
Ogni step ha validazione `Assert-Step` (throw on failure).
|
||||
|
||||
```
|
||||
Step 1 Firewall: validation only — Deploy disabled all profiles [Assert-Step]
|
||||
Step 2 Defender: validation only — Deploy set DisableAntiSpyware GPO + RTP off [Assert-Step]
|
||||
Step 3 WinRM: validation only — Deploy set Basic/Unencrypted/MaxMem/Timeout [Assert-Step]
|
||||
Step 4 Build user account (ci_build, admin, no expire) ── crea utente CI
|
||||
Step 4b UAC: validation only — Deploy set EnableLUA=0 + TokenFilterPolicy [Assert-Step]
|
||||
Step 5 CI dirs: C:\CI\{build, output, scripts} ── C:\CI deve esistere prima di WU
|
||||
Step 5b Explorer LaunchTo=1: validation only — Deploy set HKCU + Default hive [Assert-Step]
|
||||
Step 5c CI UX hardening: validation only — Deploy set lock screen, SM policy/task,
|
||||
DisableCAD, OOBE, telemetry, autologin Administrator [Assert-Step]
|
||||
Step 6 Windows Update via Scheduled Task SYSTEM ── clears Deploy GPO locks, runs WU
|
||||
Step 6b Windows Update permanently disabled (post-update lockdown)
|
||||
Step 7 .NET SDK install (channel via dotnet-install.ps1)
|
||||
Step 8 Python install (3.13.3, all-users, C:\Python)
|
||||
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
|
||||
|
||||
- **Steps 1-3 (validazioni)** — verificano prima le precondizioni Deploy (firewall off,
|
||||
Defender off, WinRM pronto) in modo che i failure emergano subito, non a metà toolchain.
|
||||
- **User + UAC (4/4b)** — crea `ci_build`; valida UAC off (set da Deploy); UAC off prima
|
||||
di dir/tool evita prompt di elevazione negli step 7-9.
|
||||
- **CI dirs prima di WU (Step 5)** — il worker WU scrive file temp in `C:\CI\`
|
||||
(`wu_worker.ps1`, `wu_result.txt`, ecc.); la directory deve esistere prima.
|
||||
- **UI tweaks (5b/5c)** — validazioni registro rapide; raggruppate prima di WU (lento).
|
||||
- **WU (Step 6)** — clears i GPO lock di Deploy (Step A), avvia COM API via SYSTEM task,
|
||||
poi Step 6b re-disabilita tutto → snapshot cattura WU permanentemente off.
|
||||
- **Toolchain (7-9) last** — WU completato, nessun scan su payload installer.
|
||||
|
||||
### Validazioni per step (sintesi)
|
||||
|
||||
| Step | Natura | Check chiave |
|
||||
| ----- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Assert | `Get-NetFirewallProfile -Profile Domain/Private/Public → Enabled=False` |
|
||||
| 2 | Assert | GPO DisableAntiSpyware=1 (set da Deploy) |
|
||||
| 3 | Assert | WinRM Running+Automatic, AllowUnencrypted=false (HTTPS-only), Auth/Basic=true, MaxMem≥2048 |
|
||||
| 4 | Operativo | User exists, enabled, PasswordNeverExpires, member of Administrators |
|
||||
| 4b | Assert | EnableLUA=0, LocalAccountTokenFilterPolicy=1 |
|
||||
| 5 | Operativo | Ogni dir Test-Path -PathType Container |
|
||||
| 5b | Assert | HKCU LaunchTo=1 |
|
||||
| 5c | Assert+Op | NoLockScreen=1, SM policy DoNotOpenAtLogon=1, SM task Disabled, DisableCAD=1, OOBE DisablePrivacyExperience=1, AllowTelemetry=0; AutoAdminLogon=1+DefaultUserName=Administrator re-affermati se assenti (Windows OOBE può resettarli al primo boot) |
|
||||
| 6 | Operativo | ResultCode ∈ {0,2,3}, no reboot required (else exit 3010) |
|
||||
| 6b | Operativo | wuauserv StartType=Disabled, NoAutoUpdate=1, DisableWindowsUpdateAccess=1 |
|
||||
| 7 | Operativo | `dotnet --version` channel match, machine PATH contiene `C:\dotnet` |
|
||||
| 8 | Operativo | `C:\Python\python.exe` esiste, version match esatto, PATH contiene `C:\Python` |
|
||||
| 9 | Operativo | MSBuild.exe esiste, esegue, PATH, `\VC\` dir presente |
|
||||
| 10 | Operativo | dotnet+python+msbuild tutti risolvibili (throw) |
|
||||
| Final | Cross-cut | WinRM, firewall, user+admin, UAC, WU disabled, tutti tool, no leftover installer in `C:\CI` |
|
||||
|
||||
---
|
||||
|
||||
## 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\WinBuild2025\WinBuild2025.vmx`
|
||||
|
||||
---
|
||||
|
||||
## Validation scripts standalone
|
||||
|
||||
Due script per validare il guest in punti specifici del flusso, indipendentemente da
|
||||
Prepare. Utili per debug, re-verifica dopo modifiche manuali, e CI automatizzata.
|
||||
|
||||
### Dopo Deploy — prima di Prepare
|
||||
|
||||
```powershell
|
||||
.\template\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129
|
||||
```
|
||||
|
||||
Controlla: Firewall off, Defender GPO, WinRM (Running/Automatic/AllowUnencrypted/Basic/MaxMem),
|
||||
UAC off, Explorer LaunchTo, NoLockScreen, Server Manager (policy+HKLM+task+HKCU),
|
||||
DisableCAD, OOBE, AllowTelemetry, **AutoAdminLogon=1**, WU GPO, wuauserv/UsoSvc=Manual.
|
||||
|
||||
### Dopo Prepare — prima di snapshot
|
||||
|
||||
```powershell
|
||||
.\template\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129
|
||||
```
|
||||
|
||||
Tutti i check Deploy + ci_build (exists/enabled/admin/PasswordNeverExpires),
|
||||
`C:\CI\{build,output,scripts}`, wuauserv/UsoSvc=Disabled, WU GPO,
|
||||
.NET SDK channel match, Python version match, MSBuild present, no leftover installer files.
|
||||
|
||||
Exit 0 = tutto OK. Exit 1 = almeno un check fallito (output indica quale).
|
||||
|
||||
---
|
||||
|
||||
## Fase E — Verifica finale
|
||||
|
||||
Dall'host:
|
||||
|
||||
```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
|
||||
@@ -0,0 +1,568 @@
|
||||
# Workflow Authoring Reference
|
||||
|
||||
> Reference document for AI agents and developers writing Gitea Actions workflows
|
||||
> for the Local CI/CD System.
|
||||
> Keep this document in sync with `runner/config.yaml` and `scripts/Invoke-CIJob.ps1`.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [System Overview](#1-system-overview)
|
||||
2. [Runner Labels](#2-runner-labels)
|
||||
3. [Shell Requirement](#3-shell-requirement)
|
||||
4. [Environment Variables (Injected by Runner)](#4-environment-variables-injected-by-runner)
|
||||
5. [Invoke-CIJob.ps1 Parameter Reference](#5-invoke-cijobps1-parameter-reference)
|
||||
6. [URL Conventions](#6-url-conventions)
|
||||
7. [Artifact Path Convention](#7-artifact-path-convention)
|
||||
8. [Workflow Patterns](#8-workflow-patterns)
|
||||
- [8.1 Windows build (host-clone mode)](#81-windows-build-host-clone-mode)
|
||||
- [8.2 Linux build (host-clone mode)](#82-linux-build-host-clone-mode)
|
||||
- [8.3 Windows build (UseGitClone mode)](#83-windows-build-usegitclone-mode)
|
||||
- [8.4 Linux build (UseGitClone mode)](#84-linux-build-usegitclone-mode)
|
||||
- [8.5 PSScriptAnalyzer lint](#85-psscriptanalyzer-lint)
|
||||
- [8.6 Matrix build (multiple configs)](#86-matrix-build-multiple-configs)
|
||||
9. [Checkout Step](#9-checkout-step)
|
||||
10. [Timeouts](#10-timeouts)
|
||||
11. [Common Mistakes](#11-common-mistakes)
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
The CI system runs entirely on a local Windows 11 host. Gitea triggers jobs; act_runner
|
||||
executes them. Every build step runs inside an ephemeral VMware VM — the runner host
|
||||
itself never invokes compilers or build tools. The entire VM lifecycle is managed by
|
||||
`Invoke-CIJob.ps1`.
|
||||
|
||||
```
|
||||
git push → Gitea → act_runner (host) → Invoke-CIJob.ps1 → VM lifecycle
|
||||
├── New-BuildVM
|
||||
├── Wait-VMReady
|
||||
├── Invoke-RemoteBuild
|
||||
├── Get-BuildArtifacts
|
||||
└── Remove-BuildVM (always)
|
||||
```
|
||||
|
||||
Supported guest OS: Windows Server 2025 / 2022 (WinRM transport), Ubuntu 24.04 (SSH transport).
|
||||
|
||||
---
|
||||
|
||||
## 2. Runner Labels
|
||||
|
||||
| Label | Configured as | Use for |
|
||||
| ---------------- | ---------------- | ----------------------------------- |
|
||||
| `windows-build` | `windows-build:host` | Windows VM builds, Windows-only tools |
|
||||
| `linux-build` | `linux-build:host` | Linux VM builds (cross-compile, GCC/Clang) |
|
||||
| `dotnet` | `dotnet:host` | Alias for windows-build (legacy) |
|
||||
| `msbuild` | `msbuild:host` | Alias for windows-build (legacy) |
|
||||
|
||||
Both `windows-build` and `linux-build` run on the same physical host. Only the
|
||||
template VM used differs. Use `windows-build` unless you specifically need a Linux
|
||||
guest (cross-compile, GCC, CMake, Python/Linux toolchains).
|
||||
|
||||
---
|
||||
|
||||
## 3. Shell Requirement
|
||||
|
||||
**Always use `shell: powershell`** (Windows PowerShell 5.1).
|
||||
|
||||
Never use `shell: pwsh` — that invokes PowerShell Core 7+ which is not the
|
||||
required runtime and may not be present on the host.
|
||||
|
||||
```yaml
|
||||
# CORRECT
|
||||
- name: Build
|
||||
shell: powershell
|
||||
run: |
|
||||
...
|
||||
|
||||
# WRONG — do not use
|
||||
- name: Build
|
||||
shell: pwsh # PowerShell 7+ — not supported
|
||||
run: |
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Environment Variables (Injected by Runner)
|
||||
|
||||
These variables are automatically available in every job step. Do not redeclare them
|
||||
in the workflow unless you need to override the default.
|
||||
|
||||
| Variable | Value (default) | Description |
|
||||
| ---------------------------- | ---------------------------------------------------- | --------------------------------------- |
|
||||
| `GITEA_CI_TEMPLATE_PATH` | `F:\CI\Templates\WinBuild2025\WinBuild2025.vmx` | Windows 2025 template VMX path |
|
||||
| `GITEA_CI_LINUX_TEMPLATE_PATH` | `F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx` | Ubuntu 24.04 template VMX path |
|
||||
| `GITEA_CI_CLONE_BASE_DIR` | `F:\CI\BuildVMs` | Directory for ephemeral VM clones |
|
||||
| `GITEA_CI_ARTIFACT_DIR` | `F:\CI\Artifacts` | Root directory for job artifacts |
|
||||
| `GITEA_CI_GUEST_CRED_TARGET` | `BuildVMGuest` | Credential Manager target for guest VM |
|
||||
| `GITEA_CI_SSH_KEY_PATH` | `F:\CI\keys\ci_linux` | SSH private key for Linux guest access |
|
||||
|
||||
Invoke-CIJob.ps1 reads `GITEA_CI_TEMPLATE_PATH` and `GITEA_CI_CLONE_BASE_DIR`
|
||||
automatically if `-TemplatePath` and `-CloneBaseDir` are not passed explicitly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Invoke-CIJob.ps1 Parameter Reference
|
||||
|
||||
Script location (fixed on host): `N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1`
|
||||
|
||||
### Mandatory parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ----------- | ------ | --------------------------------------------------------------- |
|
||||
| `-JobId` | string | Unique job ID. Use `${{ github.run_id }}` or `run_id-run_attempt`. |
|
||||
| `-RepoUrl` | string | Git URL of the repository to clone/build. See Section 6. |
|
||||
| `-Branch` | string | Branch to build. Use `${{ github.ref_name }}`. |
|
||||
|
||||
### Optional parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| ------------------------ | ------ | --------------------------- | -------------------------------------------------------------- |
|
||||
| `-Commit` | string | `''` | Specific commit SHA. Use `${{ github.sha }}`. Empty = HEAD. |
|
||||
| `-TemplatePath` | string | `$env:GITEA_CI_TEMPLATE_PATH` | Full path to template VMX. |
|
||||
| `-SnapshotName` | string | `BaseClean` | Template snapshot name to clone from. |
|
||||
| `-GuestOS` | string | `Auto` | `Windows`, `Linux`, or `Auto` (detected from VMX guestOS field). |
|
||||
| `-BuildCommand` | string | `''` | Shell command to run inside VM. Empty = `dotnet build`. |
|
||||
| `-GuestArtifactSource` | string | `dist` | Path inside VM guest from which artifacts are collected. |
|
||||
| `-Submodules` | switch | off | Pass to clone with `--recurse-submodules`. |
|
||||
| `-UseGitClone` | switch | off | Skip host-side clone; let the guest VM clone directly via HTTPS. |
|
||||
| `-GiteaCredentialTarget` | string | `GiteaPAT` | Credential Manager target for Gitea PAT (UseGitClone only). |
|
||||
| `-GuestCredentialTarget` | string | `BuildVMGuest` | Credential Manager target for Windows guest VM login. |
|
||||
| `-SshKeyPath` | string | `F:\CI\keys\ci_linux` | SSH key for Linux guest (ignored for Windows). |
|
||||
| `-SshUser` | string | `ci_build` | SSH username for Linux guest (ignored for Windows). |
|
||||
| `-CloneBaseDir` | string | `$env:GITEA_CI_CLONE_BASE_DIR` | Directory for VM clone files. |
|
||||
| `-ArtifactBaseDir` | string | `F:\CI\Artifacts` | Root artifact directory on host. |
|
||||
| `-VMIPAddress` | string | `''` | Override guest IP. Empty = auto-detected via vmrun. |
|
||||
| `-LogDir` | string | `F:\CI\Logs` | Directory for per-job transcripts and JSONL logs. |
|
||||
| `-LogRetentionDays` | int | `30` | Days before log directories are purged. `0` = disabled. |
|
||||
|
||||
Exit codes: `0` = success, `1` = failure (VM is always destroyed regardless).
|
||||
|
||||
---
|
||||
|
||||
## 6. URL Conventions
|
||||
|
||||
The `-RepoUrl` value depends on the build mode.
|
||||
|
||||
### Host-clone mode (default, no `-UseGitClone`)
|
||||
|
||||
The host runner clones the repo using the **SSH alias** defined in `~/.ssh/config`:
|
||||
|
||||
```
|
||||
ssh://gitea-ci/<owner>/<repo>.git
|
||||
```
|
||||
|
||||
- `gitea-ci` is a Host alias in the runner's SSH config pointing to Gitea's SSH port.
|
||||
- This URL is reachable **only from the host**, not from inside guest VMs.
|
||||
- After clone, source is transferred to the guest via WinRM (Windows) or tar+scp (Linux).
|
||||
|
||||
### UseGitClone mode (`-UseGitClone`)
|
||||
|
||||
The guest VM clones the repo directly via **HTTPS**:
|
||||
|
||||
```
|
||||
https://gitea.emulab.it/<owner>/<repo>.git
|
||||
```
|
||||
|
||||
- This URL must be reachable from inside the VM's NAT network.
|
||||
- For private repos, the Gitea PAT is injected from Credential Manager (`GiteaPAT` target).
|
||||
- Do not use the SSH alias URL with `-UseGitClone` — it is not accessible inside VMs.
|
||||
|
||||
### Quick reference
|
||||
|
||||
| Mode | RepoUrl to use |
|
||||
| ----------------- | ---------------------------------------------------------- |
|
||||
| Default (Windows) | `ssh://gitea-ci/<owner>/<repo>.git` |
|
||||
| Default (Linux) | `ssh://gitea-ci/<owner>/<repo>.git` |
|
||||
| UseGitClone | `https://gitea.emulab.it/<owner>/<repo>.git` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Artifact Path Convention
|
||||
|
||||
Artifacts are written to:
|
||||
|
||||
```
|
||||
F:\CI\Artifacts\<JobId>\
|
||||
```
|
||||
|
||||
Where `<JobId>` is the value passed to `-JobId`.
|
||||
|
||||
In `upload-artifact` steps use:
|
||||
|
||||
```yaml
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}\
|
||||
```
|
||||
|
||||
Or for single-file artifact:
|
||||
|
||||
```yaml
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
|
||||
```
|
||||
|
||||
`Invoke-CIJob.ps1` always produces an `artifacts.zip` at the job artifact root,
|
||||
plus the raw files extracted from the guest under the same directory.
|
||||
|
||||
---
|
||||
|
||||
## 8. Workflow Patterns
|
||||
|
||||
Place workflow files in `.gitea/workflows/` in the project repository.
|
||||
|
||||
### 8.1 Windows build (host-clone mode)
|
||||
|
||||
Standard Windows build. Host clones the repo and transfers source to the Windows VM
|
||||
via WinRM/zip transfer.
|
||||
|
||||
```yaml
|
||||
name: Build (Windows)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-build
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Run CI Job
|
||||
shell: powershell
|
||||
run: |
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-JobId '${{ github.run_id }}' `
|
||||
-RepoUrl 'ssh://gitea-ci/<owner>/<repo>.git' `
|
||||
-Branch '${{ github.ref_name }}' `
|
||||
-Commit '${{ github.sha }}' `
|
||||
-Submodules `
|
||||
-BuildCommand 'python build_plugin.py --final --dist-dir dist' `
|
||||
-GuestArtifactSource 'dist'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload artifacts
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ github.ref_name }}
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-logs-${{ github.run_id }}
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}\*.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
```
|
||||
|
||||
### 8.2 Linux build (host-clone mode)
|
||||
|
||||
Host clones the repo, compresses it with tar, and transfers it to the Ubuntu 24.04
|
||||
VM via scp. Build runs under `ci_build` user.
|
||||
|
||||
```yaml
|
||||
name: Build (Linux)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux-build
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Run CI Job (Linux)
|
||||
shell: powershell
|
||||
run: |
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-JobId '${{ github.run_id }}' `
|
||||
-RepoUrl 'ssh://gitea-ci/<owner>/<repo>.git' `
|
||||
-Branch '${{ github.ref_name }}' `
|
||||
-Commit '${{ github.sha }}' `
|
||||
-Submodules `
|
||||
-GuestOS 'Linux' `
|
||||
-TemplatePath $env:GITEA_CI_LINUX_TEMPLATE_PATH `
|
||||
-SshKeyPath $env:GITEA_CI_SSH_KEY_PATH `
|
||||
-BuildCommand 'python3 build_plugin.py --host linux --verbose' `
|
||||
-GuestArtifactSource 'dist'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload artifacts
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-linux-${{ github.ref_name }}
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
|
||||
if-no-files-found: error
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `-GuestOS 'Linux'` can be omitted if `Auto` detection is sufficient (reads VMX `guestOS` field).
|
||||
- `-TemplatePath $env:GITEA_CI_LINUX_TEMPLATE_PATH` selects the Ubuntu template.
|
||||
- `-SshKeyPath $env:GITEA_CI_SSH_KEY_PATH` uses the injected key path.
|
||||
- The default snapshot name for Linux templates is `BaseClean-Linux`. If you rely on
|
||||
`Auto`, add `-SnapshotName 'BaseClean-Linux'` explicitly.
|
||||
|
||||
### 8.3 Windows build (UseGitClone mode)
|
||||
|
||||
The Windows guest VM clones the repository directly via HTTPS. Requires Git for Windows
|
||||
in the template and the `GiteaPAT` Credential Manager entry on the host (for private repos).
|
||||
|
||||
```yaml
|
||||
name: Build (Windows, in-VM clone)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-build
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Run CI Job
|
||||
shell: powershell
|
||||
run: |
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-JobId '${{ github.run_id }}' `
|
||||
-RepoUrl 'https://gitea.emulab.it/<owner>/<repo>.git' `
|
||||
-Branch '${{ github.ref_name }}' `
|
||||
-Commit '${{ github.sha }}' `
|
||||
-Submodules `
|
||||
-UseGitClone `
|
||||
-GiteaCredentialTarget 'GiteaPAT' `
|
||||
-BuildCommand 'python build_plugin.py --final --dist-dir dist' `
|
||||
-GuestArtifactSource 'dist'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload artifacts
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ github.ref_name }}
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
|
||||
if-no-files-found: error
|
||||
```
|
||||
|
||||
### 8.4 Linux build (UseGitClone mode)
|
||||
|
||||
Linux guest clones directly. Useful when the host SSH agent is unavailable or for
|
||||
testing the full HTTPS clone path.
|
||||
|
||||
```yaml
|
||||
name: Build (Linux, in-VM clone)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: linux-build
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Run CI Job (Linux, UseGitClone)
|
||||
shell: powershell
|
||||
run: |
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-JobId '${{ github.run_id }}' `
|
||||
-RepoUrl 'https://gitea.emulab.it/<owner>/<repo>.git' `
|
||||
-Branch '${{ github.ref_name }}' `
|
||||
-Commit '${{ github.sha }}' `
|
||||
-Submodules `
|
||||
-UseGitClone `
|
||||
-GiteaCredentialTarget 'GiteaPAT' `
|
||||
-GuestOS 'Linux' `
|
||||
-TemplatePath $env:GITEA_CI_LINUX_TEMPLATE_PATH `
|
||||
-SshKeyPath $env:GITEA_CI_SSH_KEY_PATH `
|
||||
-SnapshotName 'BaseClean-Linux' `
|
||||
-BuildCommand 'python3 build_plugin.py --host linux --verbose' `
|
||||
-GuestArtifactSource 'dist'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload artifacts
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-linux-${{ github.ref_name }}
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
|
||||
if-no-files-found: error
|
||||
```
|
||||
|
||||
### 8.5 PSScriptAnalyzer lint
|
||||
|
||||
Runs on every push or PR that touches `.ps1` / `.psm1` files.
|
||||
|
||||
```yaml
|
||||
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: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Get-Module -ListAvailable 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)."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "PSScriptAnalyzer: no issues found."
|
||||
```
|
||||
|
||||
Note: The lint job uses `actions/checkout@v4` because it analyzes files inside the
|
||||
project repo (the checked-out workspace), not the CI scripts repo.
|
||||
|
||||
### 8.6 Matrix build (multiple configs)
|
||||
|
||||
```yaml
|
||||
name: Build (matrix)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-build
|
||||
timeout-minutes: 90
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
config: [x86-ansi, x86-unicode, x64-unicode]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Run CI Job
|
||||
shell: powershell
|
||||
run: |
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-JobId '${{ github.run_id }}-${{ matrix.config }}' `
|
||||
-RepoUrl 'ssh://gitea-ci/<owner>/<repo>.git' `
|
||||
-Branch '${{ github.ref_name }}' `
|
||||
-Commit '${{ github.sha }}' `
|
||||
-Submodules `
|
||||
-BuildCommand "python build_plugin.py --config ${{ matrix.config }}" `
|
||||
-GuestArtifactSource 'dist'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload artifacts
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.config }}-${{ github.ref_name }}
|
||||
path: F:\CI\Artifacts\${{ github.run_id }}-${{ matrix.config }}\artifacts.zip
|
||||
if-no-files-found: error
|
||||
```
|
||||
|
||||
Note: With `runner.capacity: 4`, up to 4 matrix jobs run in parallel. Each gets its
|
||||
own ephemeral VM. Ensure `-JobId` is unique per matrix cell.
|
||||
|
||||
---
|
||||
|
||||
## 9. Checkout Step
|
||||
|
||||
**Do not add `actions/checkout@v4` to build steps** unless the workflow itself needs
|
||||
access to repository files on the host (e.g. lint, static analysis).
|
||||
|
||||
For build jobs, `Invoke-CIJob.ps1` handles all source acquisition — either by cloning
|
||||
on the host and transferring to the VM, or by having the VM clone directly.
|
||||
|
||||
The CI scripts are at a fixed path on the runner host and do not need a checkout:
|
||||
|
||||
```yaml
|
||||
# CORRECT — direct path to CI scripts, no checkout needed
|
||||
- name: Run CI Job
|
||||
shell: powershell
|
||||
run: |
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' ...
|
||||
|
||||
# WRONG — CI scripts are not in the project repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4 # this checks out the PROJECT repo, not CI scripts
|
||||
- name: Run CI Job
|
||||
shell: powershell
|
||||
run: |
|
||||
& "$env:GITHUB_WORKSPACE\scripts\Invoke-CIJob.ps1" ... # path doesn't exist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Timeouts
|
||||
|
||||
| Build type | Recommended `timeout-minutes` |
|
||||
| --------------------------- | ----------------------------- |
|
||||
| Windows build (typical) | 90 |
|
||||
| Windows build (large/slow) | 120 |
|
||||
| Linux build (typical) | 60 |
|
||||
| Lint / static analysis | 10 |
|
||||
| Template provisioning | 180 |
|
||||
|
||||
The runner-level timeout is `2h` (from `runner/config.yaml`). Keep job timeouts
|
||||
below that value. If a job timeout fires, the VM may not be cleanly destroyed —
|
||||
the cleanup task `Cleanup-OrphanedBuildVMs.ps1` handles these cases.
|
||||
|
||||
---
|
||||
|
||||
## 11. Common Mistakes
|
||||
|
||||
| Mistake | Correct approach |
|
||||
| ------- | ---------------- |
|
||||
| `shell: pwsh` | Use `shell: powershell` (PS 5.1, not Core) |
|
||||
| SSH alias URL with `-UseGitClone` | Use `https://gitea.emulab.it/...` for UseGitClone; SSH alias is host-only |
|
||||
| HTTPS URL without `-UseGitClone` | SSH alias `ssh://gitea-ci/...` for host-clone mode |
|
||||
| `$env:GITHUB_WORKSPACE` to find CI scripts | Use hardcoded `N:\Code\Workspace\Local-CI-CD-System\scripts\` |
|
||||
| `actions/checkout@v4` in build jobs | Omit it; source is handled by Invoke-CIJob.ps1 |
|
||||
| `runs-on: ubuntu-latest` or `windows-latest` | Use `windows-build` or `linux-build` — cloud runners are not available |
|
||||
| `&&` in PowerShell run blocks | Not valid in PS 5.1; use `if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` |
|
||||
| Hardcoded IP in `-VMIPAddress` | Omit it; auto-detected via `vmrun getGuestIPAddress` |
|
||||
| `-TemplatePath` for Linux without `GITEA_CI_LINUX_TEMPLATE_PATH` | Pass `$env:GITEA_CI_LINUX_TEMPLATE_PATH` or the full path explicitly |
|
||||
| Forgetting `-SnapshotName 'BaseClean-Linux'` for Linux | Linux template uses a different snapshot name than Windows (`BaseClean`) |
|
||||
| Same `-JobId` for matrix jobs | Append matrix key: `${{ github.run_id }}-${{ matrix.config }}` |
|
||||
@@ -16,7 +16,7 @@
|
||||
- Gitea self-hosted — `http://10.10.20.11:3100`
|
||||
- act_runner v1.0.2 — Windows service su host, label `windows-build`
|
||||
- Build VMs: subnet VMnet8 NAT — `192.168.79.0/24`
|
||||
- WinRM HTTP/5985 (Basic auth, lab-only)
|
||||
- WinRM HTTPS/5986 (Basic auth over HTTPS, self-signed cert, SkipCACheck lab-only)
|
||||
|
||||
### Architettura implementata
|
||||
1. Gitea Actions enqueue job → act_runner lo prende
|
||||
@@ -0,0 +1,44 @@
|
||||
# Archived Documentation
|
||||
|
||||
Date: 2026-05-10
|
||||
|
||||
## Archived Files
|
||||
|
||||
### PLAN.md (archived 2026-05-10)
|
||||
**Reason**: Pianificazione iniziale (ultimo update 2026-05-08) superata dallo stato post Sprint 8.
|
||||
Stato corrente del sistema, decisioni tecniche e roadmap sono ora distribuiti tra:
|
||||
- `TODO.md` (root) per audit trail, summary status e backlog
|
||||
- `docs/ARCHITECTURE.md` per il design del sistema
|
||||
- `docs/CI-FLOW.md` per il flusso operativo
|
||||
- `docs/RUNBOOK.md` per il triage incident
|
||||
|
||||
Resta come riferimento storico.
|
||||
|
||||
### TEST-7.4-e2e.md
|
||||
**Reason**: Sprint 7.4 refactor validation document. Specific to §7.4 Deploy↔Setup refactor completion testing.
|
||||
**When to revive**: As a reference template for validating future template refactors. Contains reusable patterns for:
|
||||
- Deploy script validation on fresh VM
|
||||
- Setup script validation (idempotency, state verification)
|
||||
- Post-refactor sign-off checklist
|
||||
**Useful for**: §6.6 (Toolchain Tier-1) template updates, Linux template setup (§6.1)
|
||||
|
||||
## De-archived
|
||||
|
||||
### Setup-GiteaSSH.md (restored 2026-05-10)
|
||||
Originariamente archiviato come "obsolete (HTTP+credential manager)" ma di fatto il workflow
|
||||
live (`gitea/workflows/build-nsis.yml`) e `Setup-Host.ps1` continuano a usare l'alias SSH
|
||||
`gitea-ci` (porta 2222) per i clone Git. Ripristinato in `docs/Setup-GiteaSSH.md`.
|
||||
|
||||
## Active Documentation
|
||||
|
||||
All current, operational docs remain in parent directory:
|
||||
- ARCHITECTURE.md
|
||||
- BEST-PRACTICES.md
|
||||
- CI-FLOW.md
|
||||
- HOST-SETUP.md
|
||||
- WINDOWS-TEMPLATE-SETUP.md
|
||||
- LINUX-TEMPLATE-SETUP.md (draft for §6.1)
|
||||
- OPTIMIZATION.md
|
||||
- RUNBOOK.md
|
||||
- TEST-PLAN-v1.3-to-HEAD.md
|
||||
- Setup-GiteaSSH.md
|
||||
@@ -0,0 +1,169 @@
|
||||
# §7.4 — E2e test refactor Deploy↔Setup
|
||||
|
||||
Valida che il refactor §7 (2026-05-10) funzioni su VM reale:
|
||||
Deploy possiede OS hardening → Setup valida senza re-applicare.
|
||||
|
||||
**Non toccare l'existing template** (`CI-WinBuild.vmx` + snapshot `BaseClean`).
|
||||
Usare VM separata per i test.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisiti
|
||||
|
||||
- `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` presente
|
||||
- `F:\CI\ISO\VMware-tools-windows.iso` presente
|
||||
- VMware Workstation aperto, sufficiente spazio su `F:\CI\Templates\WinBuildTest\`
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Deploy su VM test
|
||||
|
||||
```powershell
|
||||
cd N:\Code\Workspace\Local-CI-CD-System\template
|
||||
|
||||
.\Deploy-WinBuild2025.ps1 `
|
||||
-WinISO 'F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso' `
|
||||
-ToolsISO 'F:\CI\ISO\VMware-tools-windows.iso' `
|
||||
-VMXPath 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' `
|
||||
-VMName 'WinBuild2025-test' `
|
||||
-ProductKey 'TVRH6-WHNXV-R9WG3-9XRFY-MY832'
|
||||
```
|
||||
|
||||
Durata attesa: 30–90 min. Exit 0 = OK, snapshot `PostInstall` preso automaticamente.
|
||||
|
||||
Se timeout su `install_complete.flag`: aumentare `-GuestPollMaxMinutes 120`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Verifica stato Deploy (§7.1 + §7.2)
|
||||
|
||||
Sostituire `192.168.79.xxx` con IP reale della VM test (`ipconfig` dentro la VM).
|
||||
|
||||
```powershell
|
||||
$ip = '192.168.79.xxx'
|
||||
$cred = Get-Credential # Administrator / WinBuild!
|
||||
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
Invoke-Command -ComputerName $ip -Credential $cred `
|
||||
-Authentication Basic -SessionOption $so -ScriptBlock {
|
||||
|
||||
"=== §7.1 Firewall ==="
|
||||
Get-NetFirewallProfile | Select-Object Name, Enabled
|
||||
|
||||
"=== §7.2 WU services ==="
|
||||
Get-Service wuauserv, UsoSvc | Select-Object Name, StartType, Status
|
||||
|
||||
"=== §7.2 WU GPO ==="
|
||||
$au = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
|
||||
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
|
||||
[PSCustomObject]@{
|
||||
NoAutoUpdate = (Get-ItemProperty $au -EA SilentlyContinue).NoAutoUpdate
|
||||
DisableWindowsUpdateAccess = (Get-ItemProperty $wu -EA SilentlyContinue).DisableWindowsUpdateAccess
|
||||
}
|
||||
|
||||
"=== §7.1 WinRM limits ==="
|
||||
[PSCustomObject]@{
|
||||
MaxMemoryPerShellMB = (Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB).Value
|
||||
IdleTimeOut = (Get-Item WSMan:\localhost\Shell\IdleTimeOut).Value
|
||||
}
|
||||
|
||||
"=== §7.1 UAC ==="
|
||||
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' |
|
||||
Select-Object EnableLUA, LocalAccountTokenFilterPolicy
|
||||
}
|
||||
```
|
||||
|
||||
**Atteso**:
|
||||
| Check | Valore atteso |
|
||||
| ---------------------------- | --------------- |
|
||||
| Firewall tutti profili | `Enabled=False` |
|
||||
| `wuauserv` StartType | `Manual` |
|
||||
| `UsoSvc` StartType | `Manual` |
|
||||
| `NoAutoUpdate` | `1` |
|
||||
| `DisableWindowsUpdateAccess` | `1` |
|
||||
| `MaxMemoryPerShellMB` | `>= 2048` |
|
||||
| `EnableLUA` | `0` |
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Prepare -SkipWindowsUpdate (Assert-Step validation)
|
||||
|
||||
```powershell
|
||||
.\Prepare-WinBuild2025.ps1 `
|
||||
-VMIPAddress 192.168.79.xxx `
|
||||
-SkipWindowsUpdate
|
||||
```
|
||||
|
||||
**Cosa verificare nel log**:
|
||||
- Step 1 (Firewall): solo `[OK]` — nessun `Set-NetFirewallProfile`
|
||||
- Step 3 (WinRM): solo `[OK]` — nessun `Enable-PSRemoting` né `winrm set`
|
||||
- Step 4b (UAC): solo `[OK]` — nessun `Set-ItemProperty EnableLUA`
|
||||
- Step 5b (Explorer): solo `[OK]`
|
||||
- Step 5c (CIUX): solo `[OK]`
|
||||
- Step 4 (user `ci_build`): crea utente — operativo, non validation
|
||||
- Step 5 (CI dirs): crea `C:\CI\{build,output,scripts}` — operativo
|
||||
- Step 6: `[Setup] Skipping Windows Update` — corretto con `-SkipWindowsUpdate`
|
||||
- Step 6b: disabilita wuauserv/UsoSvc — deve girare sempre
|
||||
- Step 7-10: .NET, Python, VS Build Tools installati
|
||||
- Final gate: tutti e 7 i check `[OK]`
|
||||
- Exit code: `0`
|
||||
|
||||
Se un Assert-Step fallisce con `[FAIL]` su Step 1/3/4b/5b/5c → Deploy non ha applicato
|
||||
quel setting → debug Deploy post-install.ps1 (controllare log nella VM).
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Prepare senza -Skip (WU lifecycle §7.2)
|
||||
|
||||
> Solo se Step 3 è passato. La VM deve avere internet (VMnet8 NAT).
|
||||
|
||||
```powershell
|
||||
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.xxx
|
||||
```
|
||||
|
||||
**Atteso**:
|
||||
- Step 6 Step A: rimuove `DisableWindowsUpdateAccess` e `NoAutoUpdate` GPO keys
|
||||
- Step 6 Step D: avvia wuauserv/UsoSvc/bits/cryptsvc (già Manual → solo Start)
|
||||
- Step 6: WU gira via SchTask SYSTEM, ResultCode ∈ {0, 2, 3}
|
||||
- Step 6b: `wuauserv StartType=Disabled`, `UsoSvc StartType=Disabled`, GPO keys re-scritti
|
||||
- Final gate `Windows Update permanently disabled` → `[OK]`
|
||||
- Exit code: `0` (o `3010` se WU richiede reboot — ripetere con `-SkipWindowsUpdate`)
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Snapshot e promozione (opzionale)
|
||||
|
||||
Se tutti i check di Step 3 e 4 passano:
|
||||
|
||||
```powershell
|
||||
# Spegni VM test
|
||||
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
|
||||
-T ws stop 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' soft
|
||||
|
||||
# Snapshot versionato
|
||||
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
|
||||
-T ws snapshot 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' `
|
||||
"BaseClean_$(Get-Date -Format yyyyMMdd)"
|
||||
```
|
||||
|
||||
Per promuovere a produzione (rimpiazza template esistente):
|
||||
1. Copia `F:\CI\Templates\WinBuildTest\` → `F:\CI\Templates\WinBuild\` (backup prima)
|
||||
2. Aggiorna `GITEA_CI_TEMPLATE_PATH` in `runner/config.yaml` se path cambia
|
||||
3. Aggiorna `GITEA_CI_SNAPSHOT_NAME` se usi nome versionato (vedi TODO §2.5)
|
||||
|
||||
---
|
||||
|
||||
## Checklist §7.4 — COMPLETATA 2026-05-10
|
||||
|
||||
- [x] Step 1: Deploy exit 0, snapshot `PostInstall` presente
|
||||
- [x] Step 2: Firewall=False, wuauserv=Manual, NoAutoUpdate=1, MaxMemory≥2048, UAC=0
|
||||
(UsoSvc era Automatic — fix applicato in Deploy; corretto manualmente sulla VM test)
|
||||
- [x] Step 3: Prepare `-SkipWindowsUpdate` exit 0, nessun Set in Step 1/3/4b/5b/5c
|
||||
- [x] Step 4: Prepare senza `-Skip` exit 0 (WU: 0 update, ResultCode=0), wuauserv=Disabled post-Step 6b
|
||||
- [x] Snapshot `BaseClean` preso con successo
|
||||
|
||||
**Fix applicati durante il test**:
|
||||
- Deploy: `UsoSvc` esplicitamente a `Manual` (era omesso, default Server 2025 = Automatic)
|
||||
- Setup cleanup: ri-applica `Disabled` su wuauserv/UsoSvc dopo DISM (WaaSMedicSvc resets StartType)
|
||||
- Deploy + Setup 5c: autologin Administrator (`AutoAdminLogon=1`, `DefaultUserName`, `DefaultPassword`, `DefaultDomainName`)
|
||||
- Nuovi: `Validate-DeployState.ps1`, `Validate-SetupState.ps1`
|
||||
@@ -47,17 +47,17 @@ jobs:
|
||||
# This script handles the entire VM lifecycle:
|
||||
# clone → start → wait ready → build → collect artifacts → destroy
|
||||
- name: Build in ephemeral VM
|
||||
shell: pwsh
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Resolve the scripts directory relative to the checked-out repo.
|
||||
# If your CI scripts are in a separate repo, adjust the path.
|
||||
$ciScriptsDir = Join-Path $env:GITHUB_WORKSPACE 'scripts'
|
||||
# CI scripts are at a fixed location on the runner host.
|
||||
# Do NOT use $env:GITHUB_WORKSPACE — the CI system is in a separate repo.
|
||||
$ciScriptsDir = 'N:\Code\Workspace\Local-CI-CD-System\scripts'
|
||||
|
||||
& "$ciScriptsDir\Invoke-CIJob.ps1" `
|
||||
-JobId "${{ github.run_id }}-${{ github.run_attempt }}" `
|
||||
-RepoUrl "${{ github.server_url }}/${{ github.repository }}.git" `
|
||||
-RepoUrl "ssh://gitea-ci/${{ github.repository }}.git" `
|
||||
-Branch "${{ github.ref_name }}" `
|
||||
-Commit "${{ github.sha }}" `
|
||||
-Submodules `
|
||||
@@ -117,3 +117,52 @@ jobs:
|
||||
# -Branch "${{ github.ref_name }}" `
|
||||
# -Configuration "${{ matrix.config }}" `
|
||||
# -VMIPAddress "${{ matrix.vm_ip }}"
|
||||
|
||||
---
|
||||
# CROSS-PLATFORM MATRIX BUILD (Windows + Linux VMs)
|
||||
# Runs the same build on both Windows and Linux ephemeral VMs in parallel.
|
||||
# Requires:
|
||||
# - runner/config.yaml labels include "windows-build:host" and "linux-build:host"
|
||||
# - Linux template provisioned: F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx
|
||||
# - SSH key at F:\CI\keys\ci_linux (ed25519, no passphrase), user ci_build
|
||||
#
|
||||
# jobs:
|
||||
# build:
|
||||
# strategy:
|
||||
# matrix:
|
||||
# target: [windows, linux]
|
||||
# fail-fast: false
|
||||
#
|
||||
# runs-on: ${{ matrix.target }}-build
|
||||
#
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - name: Build on ${{ matrix.target }}
|
||||
# shell: pwsh
|
||||
# run: |
|
||||
# $ErrorActionPreference = 'Stop'
|
||||
# $ciScriptsDir = Join-Path $env:GITHUB_WORKSPACE 'scripts'
|
||||
#
|
||||
# $templatePath = if ('${{ matrix.target }}' -eq 'linux') {
|
||||
# $env:GITEA_CI_LINUX_TEMPLATE_PATH # F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx
|
||||
# } else {
|
||||
# $env:GITEA_CI_TEMPLATE_PATH # F:\CI\Templates\WinBuild2025\WinBuild2025.vmx
|
||||
# }
|
||||
#
|
||||
# & "$ciScriptsDir\Invoke-CIJob.ps1" `
|
||||
# -JobId "${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.target }}" `
|
||||
# -RepoUrl "${{ github.server_url }}/${{ github.repository }}.git" `
|
||||
# -Branch "${{ github.ref_name }}" `
|
||||
# -Commit "${{ github.sha }}" `
|
||||
# -TemplatePath $templatePath `
|
||||
# -BuildCommand 'make all'
|
||||
#
|
||||
# - name: Upload artifacts (${{ matrix.target }})
|
||||
# if: success()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: build-${{ matrix.target }}-${{ github.sha }}
|
||||
# path: F:\CI\Artifacts\${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.target }}\
|
||||
# retention-days: 14
|
||||
# if-no-files-found: error
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Run CI Job
|
||||
shell: pwsh
|
||||
shell: powershell
|
||||
run: |
|
||||
& 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' `
|
||||
-JobId '${{ github.run_id }}' `
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nsis-plugin-nsinnounp-${{ github.ref_name }}
|
||||
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: powershell
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
+9
-1
@@ -19,13 +19,20 @@ runner:
|
||||
# Environment variables injected into every job's environment.
|
||||
envs:
|
||||
# Path to the template VM - update after provisioning the template
|
||||
GITEA_CI_TEMPLATE_PATH: "F:\\CI\\Templates\\WinBuild\\CI-WinBuild.vmx"
|
||||
GITEA_CI_TEMPLATE_PATH: "F:\\CI\\Templates\\WinBuild2025\\WinBuild2025.vmx"
|
||||
# Base directory where ephemeral clone VMs are created
|
||||
GITEA_CI_CLONE_BASE_DIR: "F:\\CI\\BuildVMs"
|
||||
# Base directory for collected build artifacts
|
||||
GITEA_CI_ARTIFACT_DIR: "F:\\CI\\Artifacts"
|
||||
# Windows Credential Manager target name for guest VM credentials
|
||||
GITEA_CI_GUEST_CRED_TARGET: "BuildVMGuest"
|
||||
# Snapshot to clone from. Default BaseClean — set to BaseClean_<yyyyMMdd> when
|
||||
# refreshing the template. Keep the previous value live for 1 week before deleting.
|
||||
# GITEA_CI_SNAPSHOT_NAME: "BaseClean"
|
||||
# Path to the Linux (Ubuntu 24.04) template VM
|
||||
GITEA_CI_LINUX_TEMPLATE_PATH: "F:\\CI\\Templates\\LinuxBuild2404\\LinuxBuild2404.vmx"
|
||||
# SSH private key for Linux guest authentication
|
||||
GITEA_CI_SSH_KEY_PATH: "F:\\CI\\keys\\ci_linux"
|
||||
|
||||
# Optional: load additional env vars from a file (one KEY=VALUE per line)
|
||||
# env_file: "F:\\CI\\runner.env"
|
||||
@@ -40,6 +47,7 @@ runner:
|
||||
- "windows-build:host"
|
||||
- "dotnet:host"
|
||||
- "msbuild:host"
|
||||
- "linux-build:host"
|
||||
|
||||
# Uncomment to ignore specific errors
|
||||
# insecure: false
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a timestamped backup of the CI template VM directory.
|
||||
|
||||
.DESCRIPTION
|
||||
Run before any template refresh (toolchain update, KMS re-activation, snapshot
|
||||
rebuild). The act_runner service is stopped for the duration so no vmrun clone
|
||||
races the file copy, then restarted automatically.
|
||||
|
||||
Restore procedure (after a bad refresh):
|
||||
Stop-Service act_runner
|
||||
Remove-Item 'F:\CI\Templates\WinBuild' -Recurse -Force
|
||||
Copy-Item 'F:\CI\Backups\Template_<stamp>' 'F:\CI\Templates\WinBuild' -Recurse
|
||||
Start-Service act_runner
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Directory containing the template VM files (.vmx, .vmdk, snapshots).
|
||||
Default: F:\CI\Templates\WinBuild
|
||||
|
||||
.PARAMETER BackupBaseDir
|
||||
Directory under which timestamped backup folders are created.
|
||||
Default: F:\CI\Backups
|
||||
|
||||
.PARAMETER KeepCount
|
||||
Number of most-recent backups to retain. Older ones are deleted.
|
||||
Default: 3
|
||||
|
||||
.PARAMETER SkipRunnerStop
|
||||
Skip stopping/restarting act_runner. Use when the service is already stopped
|
||||
or when called from a maintenance window where the runner is offline.
|
||||
|
||||
.EXAMPLE
|
||||
# Standard pre-refresh backup (run elevated)
|
||||
.\Backup-CITemplate.ps1
|
||||
|
||||
# Dry run to preview what would happen
|
||||
.\Backup-CITemplate.ps1 -WhatIf
|
||||
|
||||
# Keep 5 backups
|
||||
.\Backup-CITemplate.ps1 -KeepCount 5
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[string] $TemplatePath = 'F:\CI\Templates\WinBuild',
|
||||
|
||||
[string] $BackupBaseDir = 'F:\CI\Backups',
|
||||
|
||||
[ValidateRange(1, 20)]
|
||||
[int] $KeepCount = 3,
|
||||
|
||||
[switch] $SkipRunnerStop
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $TemplatePath -PathType Container)) {
|
||||
throw "Template directory not found: $TemplatePath"
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
$backupDest = Join-Path $BackupBaseDir "Template_$timestamp"
|
||||
|
||||
if (-not (Test-Path $BackupBaseDir)) {
|
||||
New-Item -ItemType Directory -Path $BackupBaseDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# ── Step 1: Stop runner ──────────────────────────────────────────────────────
|
||||
$runnerWasRunning = $false
|
||||
if (-not $SkipRunnerStop) {
|
||||
$svc = Get-Service -Name act_runner -ErrorAction SilentlyContinue
|
||||
if ($svc -and $svc.Status -eq 'Running') {
|
||||
if ($PSCmdlet.ShouldProcess('act_runner', 'Stop service before backup')) {
|
||||
Write-Host "[Backup-CITemplate] Stopping act_runner..."
|
||||
Stop-Service -Name act_runner -Force
|
||||
$runnerWasRunning = $true
|
||||
Write-Host "[Backup-CITemplate] act_runner stopped."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# ── Step 2: Copy template directory ───────────────────────────────────────
|
||||
if ($PSCmdlet.ShouldProcess($TemplatePath, "Copy to $backupDest")) {
|
||||
Write-Host "[Backup-CITemplate] Copying $TemplatePath -> $backupDest ..."
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Copy-Item -Path $TemplatePath -Destination $backupDest -Recurse -Force
|
||||
$sw.Stop()
|
||||
|
||||
# Size sum without Measure-Object.Sum (Set-StrictMode safe)
|
||||
$backupFiles = Get-ChildItem $backupDest -Recurse -File -ErrorAction SilentlyContinue
|
||||
$totalBytes = [long]0
|
||||
if ($backupFiles) { foreach ($f in $backupFiles) { $totalBytes += $f.Length } }
|
||||
$sizeMB = [math]::Round($totalBytes / 1MB, 0)
|
||||
|
||||
Write-Host "[Backup-CITemplate] Backup complete: $backupDest ($sizeMB MB, $($sw.Elapsed.TotalSeconds.ToString('F0'))s)"
|
||||
}
|
||||
|
||||
# ── Step 3: Prune old backups ──────────────────────────────────────────────
|
||||
$existing = Get-ChildItem $BackupBaseDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like 'Template_????????_??????' } |
|
||||
Sort-Object LastWriteTime -Descending
|
||||
|
||||
if ($existing.Count -gt $KeepCount) {
|
||||
$toRemove = $existing | Select-Object -Skip $KeepCount
|
||||
foreach ($old in $toRemove) {
|
||||
if ($PSCmdlet.ShouldProcess($old.FullName, 'Remove old backup')) {
|
||||
Write-Host "[Backup-CITemplate] Pruning old backup: $($old.Name)"
|
||||
Remove-Item $old.FullName -Recurse -Force
|
||||
}
|
||||
}
|
||||
Write-Host "[Backup-CITemplate] Retained $KeepCount backup(s)."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
# ── Step 4: Restart runner ────────────────────────────────────────────────
|
||||
if ($runnerWasRunning) {
|
||||
if ($PSCmdlet.ShouldProcess('act_runner', 'Start service after backup')) {
|
||||
Write-Host "[Backup-CITemplate] Restarting act_runner..."
|
||||
Start-Service -Name act_runner
|
||||
$newStatus = (Get-Service -Name act_runner).Status
|
||||
Write-Host "[Backup-CITemplate] act_runner status: $newStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -39,10 +39,10 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter()]
|
||||
[System.Management.Automation.PSCredential] $Credential,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
@@ -51,26 +51,85 @@ param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $HostArtifactDir,
|
||||
|
||||
[switch] $IncludeLogs
|
||||
[switch] $IncludeLogs,
|
||||
|
||||
# Guest OS type — Linux uses SCP instead of WinRM.
|
||||
[ValidateSet('Windows', 'Linux')]
|
||||
[string] $GuestOS = 'Windows',
|
||||
|
||||
# SSH private key path (used when GuestOS=Linux).
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
# SSH username (used when GuestOS=Linux).
|
||||
[string] $SshUser = 'ci_build'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
||||
|
||||
if ($GuestOS -eq 'Windows' -and $null -eq $Credential) {
|
||||
throw "Credential is required when GuestOS is Windows."
|
||||
}
|
||||
|
||||
# ── Linux branch: use scp to transfer artifacts ─────────────────────────────────────────────
|
||||
if ($GuestOS -eq 'Linux') {
|
||||
Import-Module (Join-Path $PSScriptRoot '_Transport.psm1') -Force
|
||||
|
||||
if (-not (Test-Path $HostArtifactDir)) {
|
||||
New-Item -ItemType Directory -Path $HostArtifactDir -Force | Out-Null
|
||||
Write-Host "[Get-BuildArtifacts] Created host artifact directory: $HostArtifactDir"
|
||||
}
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Transferring artifacts via SCP from $GuestArtifactPath ..."
|
||||
|
||||
Copy-SshItem `
|
||||
-Source "$GuestArtifactPath/." `
|
||||
-Destination $HostArtifactDir `
|
||||
-IP $IPAddress `
|
||||
-User $SshUser `
|
||||
-KeyPath $SshKeyPath `
|
||||
-Direction 'FromGuest' `
|
||||
-Recurse
|
||||
|
||||
$transferred = @(Get-ChildItem -Path $HostArtifactDir -Recurse -File -ErrorAction SilentlyContinue)
|
||||
if ($transferred.Count -eq 0) {
|
||||
throw "[Get-BuildArtifacts] No files found in $HostArtifactDir after SCP transfer."
|
||||
}
|
||||
Write-Host "[Get-BuildArtifacts] $($transferred.Count) file(s) collected from Linux guest."
|
||||
return
|
||||
}
|
||||
|
||||
# Ensure destination exists on host
|
||||
if (-not (Test-Path $HostArtifactDir)) {
|
||||
New-Item -ItemType Directory -Path $HostArtifactDir -Force | Out-Null
|
||||
Write-Host "[Get-BuildArtifacts] Created host artifact directory: $HostArtifactDir"
|
||||
}
|
||||
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
$sessionOptions = New-CISessionOption
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..."
|
||||
$session = New-PSSession `
|
||||
$session = $null
|
||||
$attempts = 3
|
||||
$delay = 10 # seconds between attempts
|
||||
for ($i = 1; $i -le $attempts; $i++) {
|
||||
try {
|
||||
$session = New-PSSession `
|
||||
-ComputerName $IPAddress `
|
||||
-Credential $Credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-UseSSL `
|
||||
-Port 5986 `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
break
|
||||
} catch {
|
||||
if ($i -eq $attempts) { throw }
|
||||
Write-Warning "[Get-BuildArtifacts] WinRM connect attempt $i/$attempts failed — retrying in ${delay}s... ($_)"
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# ── Verify artifact exists in guest ──────────────────────────────────
|
||||
@@ -93,7 +152,7 @@ try {
|
||||
# ── Copy logs if requested ────────────────────────────────────────────
|
||||
if ($IncludeLogs) {
|
||||
$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
|
||||
}
|
||||
|
||||
@@ -109,10 +168,11 @@ try {
|
||||
throw "Artifact was not found at expected host path after copy: $hostDestPath"
|
||||
}
|
||||
|
||||
$sizeKB = [math]::Round((Get-Item $hostDestPath).Length / 1KB, 1)
|
||||
if ($sizeKB -eq 0) {
|
||||
$fileItem = Get-Item $hostDestPath
|
||||
if ($fileItem.Length -eq 0) {
|
||||
throw "Artifact file exists but is empty: $hostDestPath"
|
||||
}
|
||||
$sizeKB = [math]::Round($fileItem.Length / 1KB, 1)
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
|
||||
return $hostDestPath
|
||||
|
||||
+273
-26
@@ -95,15 +95,25 @@ param(
|
||||
# Artifact source pattern relative to GuestWorkDir (used with BuildCommand)
|
||||
[string] $GuestArtifactSource = 'dist',
|
||||
|
||||
# Clone repo IN the VM instead of on host (requires Git in template §6.6)
|
||||
# Skips Phase 1 host clone, injects URL/branch/commit into VM for in-VM clone.
|
||||
# Prerequisite: Git for Windows installed in template.
|
||||
[switch] $UseGitClone,
|
||||
|
||||
# Credential Manager target for Gitea PAT used by -UseGitClone (private repos).
|
||||
# Store with: New-StoredCredential -Target 'GiteaPAT' -UserName '<user>' -Password '<pat>' -Persist LocalMachine
|
||||
# Ignored when -UseGitClone is not set.
|
||||
[string] $GiteaCredentialTarget = 'GiteaPAT',
|
||||
|
||||
[string] $TemplatePath = $env:GITEA_CI_TEMPLATE_PATH,
|
||||
|
||||
[string] $SnapshotName = 'BaseClean',
|
||||
[string] $SnapshotName = $(if ($env:GITEA_CI_SNAPSHOT_NAME) { $env:GITEA_CI_SNAPSHOT_NAME } else { 'BaseClean' }),
|
||||
|
||||
[string] $CloneBaseDir = $(if ($env:GITEA_CI_CLONE_BASE_DIR) { $env:GITEA_CI_CLONE_BASE_DIR } else { 'F:\CI\BuildVMs' }),
|
||||
|
||||
[string] $ArtifactBaseDir = 'F:\CI\Artifacts',
|
||||
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
|
||||
[string] $VMIPAddress = '', # if empty, auto-detected via vmrun getGuestIPAddress
|
||||
|
||||
[string] $GuestCredentialTarget = 'BuildVMGuest',
|
||||
@@ -116,7 +126,15 @@ param(
|
||||
|
||||
# Days to retain job logs. Directories older than this are purged at job end.
|
||||
# Set to 0 to disable retention/purge.
|
||||
[int] $LogRetentionDays = 30
|
||||
[int] $LogRetentionDays = 30,
|
||||
|
||||
# Guest OS type — determines transport (WinRM for Windows, SSH for Linux).
|
||||
# When 'Auto', detected from the VMX guestOS field after clone.
|
||||
[ValidateSet('Windows', 'Linux', 'Auto')]
|
||||
[string] $GuestOS = 'Auto',
|
||||
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
[string] $SshUser = 'ci_build'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -158,14 +176,17 @@ catch {
|
||||
$jobArtifactDir = Join-Path $ArtifactBaseDir $JobId
|
||||
$hostCloneDir = Join-Path $env:TEMP "ci-src-$JobId"
|
||||
$cloneVmxPath = $null
|
||||
$leaseFile = $null # §2.1 IP lease file path — released in finally
|
||||
$jsonLog = $null # §4.1 JSONL structured log path
|
||||
$startTime = Get-Date
|
||||
|
||||
# ── Start transcript ──────────────────────────────────────────────────────────
|
||||
# ── Start transcript + JSONL log ──────────────────────────────────────────────
|
||||
$transcriptStarted = $false
|
||||
try {
|
||||
$jobLogDir = Join-Path $LogDir $JobId
|
||||
if (-not (Test-Path $jobLogDir)) { New-Item -ItemType Directory -Path $jobLogDir -Force | Out-Null }
|
||||
$transcriptPath = Join-Path $jobLogDir 'invoke-ci.log'
|
||||
$jsonLog = Join-Path $jobLogDir 'invoke-ci.jsonl'
|
||||
# -Append allows starting a new transcript even when VS Code (or another host)
|
||||
# has already opened a transcript on this PowerShell session.
|
||||
Start-Transcript -Path $transcriptPath -Append -Force -ErrorAction Stop
|
||||
@@ -175,6 +196,29 @@ catch {
|
||||
Write-Warning "[Invoke-CIJob] Could not start transcript: $_"
|
||||
}
|
||||
|
||||
# ── §4.1 Structured event emitter ────────────────────────────────────────────
|
||||
# Appends a single JSON line to invoke-ci.jsonl per phase transition.
|
||||
# Silent no-op if $jsonLog is null (transcript setup failed).
|
||||
function Write-JobEvent {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Phase,
|
||||
[Parameter(Mandatory)] [string] $Status, # start | success | failure | info
|
||||
[hashtable] $Data = @{}
|
||||
)
|
||||
if (-not $script:jsonLog) { return }
|
||||
$event = [ordered]@{
|
||||
ts = (Get-Date -Format 'o')
|
||||
jobId = $script:JobId
|
||||
phase = $Phase
|
||||
status = $Status
|
||||
data = $Data
|
||||
}
|
||||
try {
|
||||
$event | ConvertTo-Json -Compress -Depth 5 |
|
||||
Add-Content -Path $script:jsonLog -Encoding UTF8 -ErrorAction Stop
|
||||
} catch { } # never let logging break the build
|
||||
}
|
||||
|
||||
Write-Host "============================================================"
|
||||
Write-Host "[Invoke-CIJob] Job : $JobId"
|
||||
Write-Host "[Invoke-CIJob] Repository : $RepoUrl"
|
||||
@@ -186,19 +230,37 @@ Write-Host "[Invoke-CIJob] Artifacts : $jobArtifactDir"
|
||||
Write-Host "[Invoke-CIJob] Started : $startTime"
|
||||
Write-Host "============================================================"
|
||||
|
||||
Write-JobEvent -Phase 'job' -Status 'start' -Data @{
|
||||
repo = $RepoUrl
|
||||
branch = $Branch
|
||||
commit = $Commit
|
||||
template = $TemplatePath
|
||||
snapshot = $SnapshotName
|
||||
}
|
||||
|
||||
$exitCode = 0
|
||||
|
||||
try {
|
||||
# ── Phase 1: Clone repo on HOST ───────────────────────────────────────
|
||||
# The build VM has no internet/LAN access (Host-Only network).
|
||||
# Source is cloned here on the host, then copied into the VM via WinRM.
|
||||
# ── Phase 1: Clone repo (HOST or GUEST) ───────────────────────────────
|
||||
if ($UseGitClone) {
|
||||
# §3.3: Clone in guest VM instead of host. Skip host clone phase.
|
||||
# PAT will be injected at runtime (Phase 5) from Credential Manager.
|
||||
Write-Host "`n[Phase 1/6] Skipping host clone — will clone in guest VM (-UseGitClone)..."
|
||||
Write-JobEvent -Phase 'phase1.clone-repo' -Status 'skipped' -Data @{ method = 'in-vm-clone' }
|
||||
}
|
||||
else {
|
||||
# Default: Clone on host, zip, transfer to guest via WinRM.
|
||||
Write-Host "`n[Phase 1/6] Cloning repository on host..."
|
||||
Write-JobEvent -Phase 'phase1.clone-repo' -Status 'start'
|
||||
if (Test-Path $hostCloneDir) { Remove-Item $hostCloneDir -Recurse -Force }
|
||||
$gitArgs = @('clone', '--depth', '1', '--branch', $Branch)
|
||||
if ($Submodules) { $gitArgs += '--recurse-submodules' }
|
||||
$gitArgs = @('clone', '--depth', '1', '--progress', '--branch', $Branch)
|
||||
if ($Submodules) { $gitArgs += @('--recurse-submodules', '--shallow-submodules') }
|
||||
$gitArgs += @($RepoUrl, $hostCloneDir)
|
||||
Write-Host "[Invoke-CIJob] git $($gitArgs -join ' ')"
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$gitOutput = & git @gitArgs 2>&1 | ForEach-Object { "$_" }
|
||||
# Stream git stdout/stderr live (otherwise heavy clones with submodules
|
||||
# look frozen for a long time). Tee for diagnostic in case of failure.
|
||||
$gitOutput = & git @gitArgs 2>&1 | Tee-Object -Variable streamed | ForEach-Object { Write-Host " [git] $_"; "$_" }
|
||||
$gitExit = $LASTEXITCODE
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($gitExit -ne 0) {
|
||||
@@ -208,15 +270,59 @@ try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
|
||||
$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'
|
||||
if ($gitExit -ne 0) {
|
||||
throw "git checkout $Commit failed (exit $gitExit):`n$($gitOutput -join "`n")"
|
||||
}
|
||||
}
|
||||
Write-Host "[Invoke-CIJob] Source cloned to: $hostCloneDir"
|
||||
Write-JobEvent -Phase 'phase1.clone-repo' -Status 'success' -Data @{ dir = $hostCloneDir }
|
||||
}
|
||||
|
||||
# ── Phase 2: Clone VM ─────────────────────────────────────────────────
|
||||
Write-Host "`n[Phase 2/6] Cloning VM..."
|
||||
# ── Phase 2-3b: Clone + Start + IP (serialized via file-based mutex) ────
|
||||
# §2.1: With capacity:4 and DHCP on VMnet8, concurrent vmrun starts can race
|
||||
# and produce two VMs with the same DHCP lease. A file-based mutex ensures
|
||||
# only one job is in the clone→start→IP phase at a time. After the IP is
|
||||
# acquired and written to a lease file the lock is released, so build phases
|
||||
# (the slow part) run fully in parallel. Lease is released in finally.
|
||||
$stateDir = 'F:\CI\State'
|
||||
$leasesDir = Join-Path $stateDir 'ip-leases'
|
||||
$lockPath = Join-Path $stateDir 'vm-start.lock'
|
||||
if (-not (Test-Path $leasesDir)) { New-Item -ItemType Directory -Path $leasesDir -Force | Out-Null }
|
||||
|
||||
Write-Host "`n[Phase 2/6] Acquiring VM-start lock (prevents DHCP collision with capacity>1)..."
|
||||
Write-JobEvent -Phase 'phase2-3b.vm-start' -Status 'start'
|
||||
$lockStream = $null
|
||||
$lockDeadline = (Get-Date).AddMinutes(10)
|
||||
while ($true) {
|
||||
try {
|
||||
$lockStream = [System.IO.File]::Open(
|
||||
$lockPath,
|
||||
[System.IO.FileMode]::OpenOrCreate,
|
||||
[System.IO.FileAccess]::ReadWrite,
|
||||
[System.IO.FileShare]::None)
|
||||
break
|
||||
} catch [System.IO.IOException] {
|
||||
if ((Get-Date) -ge $lockDeadline) {
|
||||
throw "[Phase 2] VM-start lock timeout after 10 min — a concurrent job may be stuck."
|
||||
}
|
||||
Write-Host "[Invoke-CIJob] VM-start lock busy, retrying in 5s..."
|
||||
Start-Sleep -Seconds 5
|
||||
}
|
||||
}
|
||||
Write-Host "[Invoke-CIJob] VM-start lock acquired."
|
||||
|
||||
try {
|
||||
# ── Phase 2: Clone VM ─────────────────────────────────────────────
|
||||
Write-Host "[Phase 2/6] Cloning VM..."
|
||||
$cloneVmxPath = & "$scriptDir\New-BuildVM.ps1" `
|
||||
-TemplatePath $TemplatePath `
|
||||
-SnapshotName $SnapshotName `
|
||||
@@ -224,7 +330,7 @@ try {
|
||||
-JobId $JobId `
|
||||
-VmrunPath $VmrunPath
|
||||
|
||||
# ── Phase 3: Start VM ─────────────────────────────────────────────────
|
||||
# ── Phase 3: Start VM ─────────────────────────────────────────────
|
||||
Write-Host "`n[Phase 3/6] Starting VM..."
|
||||
$startOutput = & $VmrunPath -T ws start $cloneVmxPath nogui 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
@@ -232,46 +338,176 @@ try {
|
||||
}
|
||||
Write-Host "[Invoke-CIJob] VM started."
|
||||
|
||||
# ── Phase 3b: Resolve VM IP if not provided ─────────────────────────
|
||||
# ── Phase 3b: Resolve VM IP ───────────────────────────────────────
|
||||
if ([string]::IsNullOrWhiteSpace($VMIPAddress)) {
|
||||
Write-Host "[Invoke-CIJob] Auto-detecting VM IP via vmrun getGuestIPAddress..."
|
||||
$detectedIP = & $VmrunPath getGuestIPAddress $cloneVmxPath -wait 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or $detectedIP -notmatch '^(\d{1,3}\.){3}\d{1,3}$') {
|
||||
throw "Could not detect VM IP address: $detectedIP"
|
||||
# Primary method: vmrun readVariable guestVar guestinfo.ci-ip
|
||||
# The guest ci-report-ip.service writes its IP via vmware-rpctool on every boot.
|
||||
# This uses the VMware VMCI channel — reliable, official API, independent of
|
||||
# whether TCP/IP is reachable from the host.
|
||||
#
|
||||
# Fallback: vmrun getGuestIPAddress (works on Windows guests; unreliable on Linux).
|
||||
Write-Host "[Invoke-CIJob] Auto-detecting VM IP via guestinfo.ci-ip (polling, max 120s)..."
|
||||
$ipDeadline = (Get-Date).AddSeconds(120)
|
||||
$detectedIP = ''
|
||||
$ipOut = ''
|
||||
$ipAttempt = 0
|
||||
while ((Get-Date) -lt $ipDeadline) {
|
||||
$ipAttempt++
|
||||
|
||||
# Primary: guestinfo written by ci-report-ip.service via vmware-rpctool.
|
||||
# NOTE: rpctool sets 'guestinfo.ci-ip' but vmrun readVariable guestVar
|
||||
# reads it WITHOUT the 'guestinfo.' prefix (the prefix is implicit in
|
||||
# the guestVar namespace). Reading with the full key always returns ''.
|
||||
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
$ipOut = (& $VmrunPath -T ws readVariable $cloneVmxPath guestVar 'ci-ip' 2>&1).Trim()
|
||||
$ipRc = $LASTEXITCODE
|
||||
$ErrorActionPreference = $savedEap
|
||||
if ($ipAttempt -le 3 -or ($ipAttempt % 10 -eq 0)) {
|
||||
Write-Host "[Invoke-CIJob] [attempt $ipAttempt] guestVar ci-ip: rc=$ipRc out='$ipOut'"
|
||||
}
|
||||
$VMIPAddress = $detectedIP.Trim()
|
||||
if ($ipRc -eq 0 -and $ipOut -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
|
||||
$detectedIP = $ipOut
|
||||
Write-Host "[Invoke-CIJob] IP via guestinfo: $detectedIP"
|
||||
break
|
||||
}
|
||||
|
||||
# Fallback: vmrun getGuestIPAddress (reliable on Windows guests)
|
||||
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
$ipOut2 = (& $VmrunPath -T ws getGuestIPAddress $cloneVmxPath 2>&1).Trim()
|
||||
$ipRc2 = $LASTEXITCODE
|
||||
$ErrorActionPreference = $savedEap
|
||||
if ($ipRc2 -eq 0 -and $ipOut2 -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
|
||||
$detectedIP = $ipOut2
|
||||
Write-Host "[Invoke-CIJob] IP via vmrun getGuestIPAddress: $detectedIP"
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
if ($detectedIP -eq '') {
|
||||
throw "Could not detect VM IP address after 120s. Ensure ci-report-ip.service is enabled in the guest template (Linux) or open-vm-tools is running (Windows)."
|
||||
}
|
||||
$VMIPAddress = $detectedIP
|
||||
Write-Host "[Invoke-CIJob] Detected VM IP: $VMIPAddress"
|
||||
}
|
||||
|
||||
# ── Lease IP ──────────────────────────────────────────────────────
|
||||
# If two VMs somehow got the same IP (DHCP edge case), fail fast here
|
||||
# rather than letting both jobs write to the same WinRM target.
|
||||
$leaseFile = Join-Path $leasesDir "$($VMIPAddress.Replace('.', '-')).lease"
|
||||
if (Test-Path $leaseFile) {
|
||||
$holder = (Get-Content $leaseFile -Raw -ErrorAction SilentlyContinue).Trim()
|
||||
throw "IP collision: $VMIPAddress already leased by job '$holder'. Retry or reduce runner capacity."
|
||||
}
|
||||
[System.IO.File]::WriteAllText($leaseFile, $JobId)
|
||||
Write-Host "[Invoke-CIJob] IP $VMIPAddress leased for job $JobId."
|
||||
Write-JobEvent -Phase 'phase2-3b.vm-start' -Status 'success' -Data @{ ip = $VMIPAddress; vmx = $cloneVmxPath }
|
||||
|
||||
} finally {
|
||||
# Release lock — next waiting job can now enter clone→start→IP phase
|
||||
if ($lockStream) {
|
||||
$lockStream.Close()
|
||||
$lockStream.Dispose()
|
||||
$lockStream = $null
|
||||
}
|
||||
Remove-Item $lockPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "[Invoke-CIJob] VM-start lock released."
|
||||
}
|
||||
# ── Resolve GuestOS from VMX if Auto ────────────────────────────────────
|
||||
if ($GuestOS -eq 'Auto' -and $cloneVmxPath) {
|
||||
$vmxContent = Get-Content -LiteralPath $cloneVmxPath -Raw -ErrorAction SilentlyContinue
|
||||
if ($vmxContent -match 'guestOS\s*=\s*"([^"]+)"') {
|
||||
$detectedOS = $Matches[1]
|
||||
$GuestOS = if ($detectedOS -like '*ubuntu*' -or $detectedOS -like '*linux*') { 'Linux' } else { 'Windows' }
|
||||
Write-Host "[Invoke-CIJob] Auto-detected GuestOS: $GuestOS (VMX guestOS=$detectedOS)"
|
||||
} else {
|
||||
$GuestOS = 'Windows'
|
||||
Write-Host "[Invoke-CIJob] Could not detect GuestOS from VMX — defaulting to Windows"
|
||||
}
|
||||
}
|
||||
# ── Phase 4: Wait for readiness ───────────────────────────────────────
|
||||
Write-Host "`n[Phase 4/6] Waiting for VM readiness..."
|
||||
& "$scriptDir\Wait-VMReady.ps1" `
|
||||
-VMPath $cloneVmxPath `
|
||||
-IPAddress $VMIPAddress `
|
||||
-VmrunPath $VmrunPath
|
||||
Write-JobEvent -Phase 'phase4.wait-ready' -Status 'start' -Data @{ ip = $VMIPAddress }
|
||||
$waitParams = @{
|
||||
VMPath = $cloneVmxPath
|
||||
IPAddress = $VMIPAddress
|
||||
VmrunPath = $VmrunPath
|
||||
}
|
||||
if ($GuestOS -eq 'Linux') {
|
||||
$waitParams['Transport'] = 'SSH'
|
||||
$waitParams['SshKeyPath'] = $SshKeyPath
|
||||
$waitParams['SshUser'] = $SshUser
|
||||
}
|
||||
& "$scriptDir\Wait-VMReady.ps1" @waitParams
|
||||
Write-JobEvent -Phase 'phase4.wait-ready' -Status 'success'
|
||||
|
||||
# ── Phase 5: Remote build (copy source + build inside VM) ─────────────
|
||||
Write-Host "`n[Phase 5/6] Invoking remote build..."
|
||||
Write-JobEvent -Phase 'phase5.build' -Status 'start'
|
||||
$remoteBuildParams = @{
|
||||
IPAddress = $VMIPAddress
|
||||
Credential = $credential
|
||||
HostSourceDir = $hostCloneDir
|
||||
Configuration = $Configuration
|
||||
}
|
||||
if ($GuestOS -eq 'Linux') {
|
||||
$remoteBuildParams['GuestOS'] = 'Linux'
|
||||
$remoteBuildParams['SshKeyPath'] = $SshKeyPath
|
||||
$remoteBuildParams['SshUser'] = $SshUser
|
||||
$remoteBuildParams.Remove('Credential')
|
||||
if ($UseGitClone) {
|
||||
# In-guest git clone (requires Gitea reachable + PAT/key configured in guest)
|
||||
$remoteBuildParams['CloneUrl'] = $RepoUrl
|
||||
$remoteBuildParams['CloneBranch'] = $Branch
|
||||
$remoteBuildParams['GiteaCredentialTarget'] = $GiteaCredentialTarget
|
||||
if ($Commit -ne '') { $remoteBuildParams['CloneCommit'] = $Commit }
|
||||
if ($Submodules) { $remoteBuildParams['CloneSubmodules'] = $true }
|
||||
} else {
|
||||
# Default: host-cloned source shipped to guest via tar+scp
|
||||
$remoteBuildParams['HostSourceDir'] = $hostCloneDir
|
||||
}
|
||||
} else {
|
||||
if ($UseGitClone) {
|
||||
# §3.3: Clone in VM — pass URL/branch/commit instead of HostSourceDir
|
||||
$remoteBuildParams['CloneUrl'] = $RepoUrl
|
||||
$remoteBuildParams['CloneBranch'] = $Branch
|
||||
$remoteBuildParams['GiteaCredentialTarget'] = $GiteaCredentialTarget
|
||||
if ($Commit -ne '') { $remoteBuildParams['CloneCommit'] = $Commit }
|
||||
if ($Submodules) { $remoteBuildParams['CloneSubmodules'] = $true }
|
||||
}
|
||||
else {
|
||||
# Default: Use pre-cloned source from host
|
||||
$remoteBuildParams['HostSourceDir'] = $hostCloneDir
|
||||
}
|
||||
}
|
||||
if ($BuildCommand -ne '') { $remoteBuildParams['BuildCommand'] = $BuildCommand }
|
||||
if ($GuestArtifactSource -ne '') { $remoteBuildParams['GuestArtifactSource'] = $GuestArtifactSource }
|
||||
& "$scriptDir\Invoke-RemoteBuild.ps1" @remoteBuildParams
|
||||
Write-JobEvent -Phase 'phase5.build' -Status 'success'
|
||||
|
||||
# ── Phase 6: Collect artifacts ────────────────────────────────────────
|
||||
Write-Host "`n[Phase 6/6] Collecting artifacts..."
|
||||
Write-JobEvent -Phase 'phase6.artifacts' -Status 'start'
|
||||
if ($GuestOS -eq 'Linux') {
|
||||
& "$scriptDir\Get-BuildArtifacts.ps1" `
|
||||
-IPAddress $VMIPAddress `
|
||||
-GuestOS 'Linux' `
|
||||
-SshKeyPath $SshKeyPath `
|
||||
-SshUser $SshUser `
|
||||
-GuestArtifactPath '/opt/ci/output' `
|
||||
-HostArtifactDir $jobArtifactDir `
|
||||
-IncludeLogs
|
||||
} else {
|
||||
& "$scriptDir\Get-BuildArtifacts.ps1" `
|
||||
-IPAddress $VMIPAddress `
|
||||
-Credential $credential `
|
||||
-GuestArtifactPath 'C:\CI\output\artifacts.zip' `
|
||||
-HostArtifactDir $jobArtifactDir `
|
||||
-IncludeLogs
|
||||
}
|
||||
|
||||
$elapsed = (Get-Date) - $startTime
|
||||
Write-JobEvent -Phase 'phase6.artifacts' -Status 'success' -Data @{ dir = $jobArtifactDir }
|
||||
Write-JobEvent -Phase 'job' -Status 'success' -Data @{ elapsedSec = [int]$elapsed.TotalSeconds }
|
||||
Write-Host "`n============================================================"
|
||||
Write-Host "[Invoke-CIJob] SUCCESS — Job $JobId completed in $($elapsed.ToString('mm\:ss'))"
|
||||
Write-Host "[Invoke-CIJob] Artifacts: $jobArtifactDir"
|
||||
@@ -280,12 +516,22 @@ try {
|
||||
catch {
|
||||
$exitCode = 1
|
||||
$elapsed = (Get-Date) - $startTime
|
||||
Write-JobEvent -Phase 'job' -Status 'failure' -Data @{
|
||||
elapsedSec = [int]$elapsed.TotalSeconds
|
||||
error = "$_"
|
||||
}
|
||||
Write-Host "`n============================================================"
|
||||
Write-Host "[Invoke-CIJob] FAILURE — Job $JobId failed after $($elapsed.ToString('mm\:ss'))"
|
||||
Write-Host "Error: $_"
|
||||
Write-Host "============================================================"
|
||||
}
|
||||
finally {
|
||||
# ── Release IP lease (§2.1) ───────────────────────────────────────────
|
||||
if ($leaseFile -and (Test-Path $leaseFile)) {
|
||||
Remove-Item $leaseFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "[Cleanup] IP lease released: $VMIPAddress"
|
||||
}
|
||||
|
||||
# ── Always destroy the VM ─────────────────────────────────────────────
|
||||
if ($null -ne $cloneVmxPath -and (Test-Path $cloneVmxPath -PathType Leaf)) {
|
||||
Write-Host "`n[Cleanup] Destroying ephemeral VM..."
|
||||
@@ -294,8 +540,9 @@ finally {
|
||||
else {
|
||||
Write-Host "[Cleanup] No VM to destroy (clone was not created or already gone)."
|
||||
}
|
||||
# ── Always clean up host-side git clone ──────────────────────────────
|
||||
if (Test-Path $hostCloneDir) {
|
||||
# ── Clean up host-side git clone (unless in-VM clone was used) ────────
|
||||
# When -UseGitClone, $hostCloneDir is never created
|
||||
if (-not $UseGitClone -and (Test-Path $hostCloneDir)) {
|
||||
Write-Host "[Cleanup] Removing host source clone ($hostCloneDir)..."
|
||||
Remove-Item $hostCloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
+329
-36
@@ -1,19 +1,27 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Copies pre-cloned source from host into a build VM and runs dotnet build.
|
||||
Executes a build inside a guest VM (via WinRM). Supports two clone modes:
|
||||
(1) Host-side clone: copy pre-cloned source via WinRM zip, or
|
||||
(2) Guest-side clone: clone repo directly in VM via git (§3.3, requires Git)
|
||||
|
||||
.DESCRIPTION
|
||||
The caller (Invoke-CIJob.ps1) is responsible for cloning the repository
|
||||
on the HOST before calling this script. This script then:
|
||||
1. Compresses the local source tree on the host, copies the archive into
|
||||
the VM via WinRM (single file = much faster than recursive Copy-Item),
|
||||
then expands it inside the VM.
|
||||
2. Runs dotnet restore and dotnet build inside the VM
|
||||
3. Packages the output as C:\CI\output\artifacts.zip inside the VM
|
||||
Called by Invoke-CIJob.ps1 to orchestrate the build inside the VM.
|
||||
|
||||
The build VM does NOT need internet access or git. All network I/O happens
|
||||
on the host before this script is called.
|
||||
**Mode 1: Host-side (default)** — Caller provides pre-cloned source:
|
||||
1. Compresses the source tree on host, copies archive into VM via WinRM
|
||||
2. Runs dotnet restore + dotnet build inside VM
|
||||
3. Packages output as C:\CI\output\artifacts.zip
|
||||
|
||||
**Mode 2: Guest-side (§3.3, -UseGitClone in Invoke-CIJob)** — Repo cloned in VM:
|
||||
1. Clone repo directly into VM via git (eliminates host-zip-transfer overhead)
|
||||
2. Runs dotnet restore + dotnet build inside VM
|
||||
3. Packages output same as Mode 1
|
||||
|
||||
Supply either -HostSourceDir (Mode 1) or -CloneUrl (Mode 2), not both.
|
||||
|
||||
In Mode 2, PAT for private repos is read from Credential Manager inside
|
||||
this script's WinRM session (§1.5: no argv, no log, cleanup at end).
|
||||
|
||||
.PARAMETER IPAddress
|
||||
IP of the build VM (e.g. 192.168.11.101).
|
||||
@@ -24,10 +32,25 @@
|
||||
$cred = Get-StoredCredential -Target 'BuildVMGuest'
|
||||
|
||||
.PARAMETER HostSourceDir
|
||||
Full path on the HOST to the already-cloned repository directory.
|
||||
This directory is copied into the VM as the build working directory.
|
||||
[Mode 1] Full path on HOST to already-cloned repository (host-side clone mode).
|
||||
Mutually exclusive with -CloneUrl. When omitted, -CloneUrl must be provided.
|
||||
Example: C:\Users\user\AppData\Local\Temp\ci-src-run-42
|
||||
|
||||
.PARAMETER CloneUrl
|
||||
[Mode 2, §3.3] Git URL to clone directly in VM (guest-side clone mode).
|
||||
Mutually exclusive with -HostSourceDir. When provided, repo cloned via git inside VM.
|
||||
PAT (if needed) read from Credential Manager at runtime.
|
||||
Example: https://gitea.emulab.it/Simone/myrepo.git
|
||||
|
||||
.PARAMETER CloneBranch
|
||||
[Mode 2] Branch to clone. Ignored in Mode 1. Default: main
|
||||
|
||||
.PARAMETER CloneCommit
|
||||
[Mode 2] Specific commit SHA to checkout after clone. Ignored in Mode 1.
|
||||
|
||||
.PARAMETER CloneSubmodules
|
||||
[Mode 2] Pass --recurse-submodules to git clone. Ignored in Mode 1.
|
||||
|
||||
.PARAMETER Configuration
|
||||
MSBuild/dotnet build configuration. Default: Release
|
||||
|
||||
@@ -49,15 +72,37 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[Parameter()]
|
||||
[System.Management.Automation.PSCredential] $Credential,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateScript({ Test-Path $_ -PathType Container })]
|
||||
[string] $HostSourceDir,
|
||||
# [Mode 1] Host-side clone — pre-cloned source directory on HOST
|
||||
[ValidateScript({
|
||||
if ($_ -and -not (Test-Path $_ -PathType Container)) {
|
||||
throw "HostSourceDir path does not exist: $_"
|
||||
}
|
||||
return $true
|
||||
})]
|
||||
[string] $HostSourceDir = '',
|
||||
|
||||
# [Mode 2, §3.3] Guest-side clone — Git URL for in-VM clone
|
||||
[string] $CloneUrl = '',
|
||||
|
||||
# [Mode 2] Branch to clone (default: main). Ignored if HostSourceDir provided.
|
||||
[string] $CloneBranch = 'main',
|
||||
|
||||
# [Mode 2] Specific commit SHA to checkout. Ignored if HostSourceDir provided.
|
||||
[string] $CloneCommit = '',
|
||||
|
||||
# [Mode 2] Clone with --recurse-submodules. Ignored if HostSourceDir provided.
|
||||
[switch] $CloneSubmodules,
|
||||
|
||||
# [Mode 2] Credential Manager target for Gitea PAT (private repos).
|
||||
# Store with: New-StoredCredential -Target 'GiteaPAT' -UserName '<user>' -Password '<pat>' -Persist LocalMachine
|
||||
# Leave empty for public repos (no auth needed).
|
||||
[string] $GiteaCredentialTarget = 'GiteaPAT',
|
||||
|
||||
[string] $Configuration = 'Release',
|
||||
|
||||
@@ -73,14 +118,170 @@ param(
|
||||
|
||||
[string] $GuestWorkDir = 'C:\CI\build',
|
||||
|
||||
[string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip'
|
||||
[string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip',
|
||||
|
||||
# Redirect dotnet NuGet package store and pip download cache to VMware shared
|
||||
# folders on the host (F:\CI\Cache\NuGet and F:\CI\Cache\pip).
|
||||
# Requires the template VMX to have been configured by Set-TemplateSharedFolders.ps1
|
||||
# and VMware Tools HGFS driver present in the guest.
|
||||
[switch] $UseSharedCache,
|
||||
|
||||
# Guest OS type — Linux uses SSH instead of WinRM.
|
||||
[ValidateSet('Windows', 'Linux')]
|
||||
[string] $GuestOS = 'Windows',
|
||||
|
||||
# SSH private key path (used when GuestOS=Linux).
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
# SSH username (used when GuestOS=Linux).
|
||||
[string] $SshUser = 'ci_build',
|
||||
|
||||
# Work directory inside the Linux guest.
|
||||
[string] $GuestLinuxWorkDir = '/opt/ci/build',
|
||||
|
||||
# Output directory inside the Linux guest.
|
||||
[string] $GuestLinuxOutputDir = '/opt/ci/output'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Session options: skip SSL certificate checks for self-signed (lab use) ──
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
||||
|
||||
# ── Validate mutually exclusive modes ─────────────────────────────────────
|
||||
$hostCloneMode = -not [string]::IsNullOrWhiteSpace($HostSourceDir)
|
||||
$guestCloneMode = -not [string]::IsNullOrWhiteSpace($CloneUrl)
|
||||
if ($hostCloneMode -and $guestCloneMode) {
|
||||
throw "Provide either -HostSourceDir (host-side clone) or -CloneUrl (guest-side clone), not both."
|
||||
}
|
||||
if (-not $hostCloneMode -and -not $guestCloneMode) {
|
||||
throw "Provide either -HostSourceDir (host-side clone) or -CloneUrl (guest-side clone)."
|
||||
}
|
||||
if ($guestCloneMode -and -not $CloneBranch) {
|
||||
throw "CloneBranch is required when using -CloneUrl mode."
|
||||
}
|
||||
|
||||
if ($GuestOS -eq 'Windows' -and $null -eq $Credential) {
|
||||
throw "Credential is required when GuestOS is Windows."
|
||||
}
|
||||
|
||||
if ($GuestOS -eq 'Linux') {
|
||||
Import-Module (Join-Path $PSScriptRoot '_Transport.psm1') -Force
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Linux build mode — SSH transport"
|
||||
|
||||
# Step 1: Clean work dir (parent permissions handled by template setup)
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
||||
-Command "rm -rf '$GuestLinuxWorkDir' && mkdir -p '$GuestLinuxWorkDir'"
|
||||
|
||||
if ($hostCloneMode) {
|
||||
# ── Mode 1 (Linux): tar host source -> scp -> untar in guest ──
|
||||
Write-Host "[Invoke-RemoteBuild] Packaging host source ($HostSourceDir) ..."
|
||||
$tarName = "ci-src-$([Guid]::NewGuid().ToString('N').Substring(0,8)).tar.gz"
|
||||
$hostTar = Join-Path $env:TEMP $tarName
|
||||
$guestTar = "/tmp/$tarName"
|
||||
if (Test-Path $hostTar) { Remove-Item $hostTar -Force }
|
||||
# bsdtar on Windows 10/11 supports -C and gz natively.
|
||||
& tar -czf $hostTar -C $HostSourceDir .
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[Invoke-RemoteBuild] tar failed packaging $HostSourceDir (exit $LASTEXITCODE)"
|
||||
}
|
||||
try {
|
||||
Write-Host "[Invoke-RemoteBuild] Copying source archive to guest:$guestTar ..."
|
||||
Copy-SshItem -Source $hostTar -Destination $guestTar `
|
||||
-IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Direction ToGuest
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Extracting source in guest ($GuestLinuxWorkDir) ..."
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
||||
-Command "tar -xzf '$guestTar' -C '$GuestLinuxWorkDir' && rm -f '$guestTar'"
|
||||
}
|
||||
finally {
|
||||
if (Test-Path $hostTar) { Remove-Item $hostTar -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
else {
|
||||
# ── Mode 2 (Linux): in-guest git clone ──
|
||||
$cloneCmd = "git clone --depth 1 --branch '$CloneBranch'"
|
||||
if ($CloneSubmodules) { $cloneCmd += ' --recurse-submodules' }
|
||||
|
||||
# PAT injection for private repos
|
||||
$cloneTargetUrl = $CloneUrl
|
||||
if ($GiteaCredentialTarget -ne '') {
|
||||
try {
|
||||
Import-Module CredentialManager -ErrorAction Stop
|
||||
$pat = Get-StoredCredential -Target $GiteaCredentialTarget -ErrorAction Stop
|
||||
if ($pat) {
|
||||
$cloneTargetUrl = $CloneUrl -replace '(https?://)', ('$1' + [uri]::EscapeDataString($pat.UserName) + ':' + [uri]::EscapeDataString($pat.GetNetworkCredential().Password) + '@')
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "[Invoke-RemoteBuild] Could not load PAT from Credential Manager ($GiteaCredentialTarget) — cloning without auth (public repo?)."
|
||||
}
|
||||
}
|
||||
|
||||
$cloneCmd += " '$CloneUrl' '$GuestLinuxWorkDir'" # non-authed URL for logging
|
||||
Write-Host "[Invoke-RemoteBuild] Cloning $CloneUrl into guest $GuestLinuxWorkDir ..."
|
||||
|
||||
if ($cloneTargetUrl -ne $CloneUrl) {
|
||||
# PAT injected via env var inside SSH session — never in args
|
||||
$envCloneCmd = "export GIT_CLONE_URL='$cloneTargetUrl'; git clone --depth 1 --branch '$CloneBranch'"
|
||||
if ($CloneSubmodules) { $envCloneCmd += ' --recurse-submodules' }
|
||||
$envCloneCmd += " `"`$GIT_CLONE_URL`" '$GuestLinuxWorkDir'; unset GIT_CLONE_URL"
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Command $envCloneCmd
|
||||
} else {
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Command $cloneCmd
|
||||
}
|
||||
|
||||
# Checkout specific commit if requested
|
||||
if ($CloneCommit -ne '') {
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
||||
-Command "git -C '$GuestLinuxWorkDir' checkout '$CloneCommit'"
|
||||
}
|
||||
}
|
||||
|
||||
# Step 3: Run build command (always cd into workdir first)
|
||||
if ($BuildCommand -ne '') {
|
||||
$buildCmd = "cd '$GuestLinuxWorkDir' && $BuildCommand"
|
||||
} else {
|
||||
$buildCmd = "cd '$GuestLinuxWorkDir' && make"
|
||||
}
|
||||
Write-Host "[Invoke-RemoteBuild] Running build: $buildCmd"
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath -Command $buildCmd
|
||||
|
||||
# Step 4: Stage artifact tree under $GuestLinuxOutputDir for SCP collection
|
||||
$outDir = $GuestLinuxOutputDir
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
||||
-Command "rm -rf '$outDir' && mkdir -p '$outDir'"
|
||||
|
||||
$artSrc = if ($GuestArtifactSource -ne '') { $GuestArtifactSource } else { 'dist' }
|
||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
||||
-Command "if [ -d '$GuestLinuxWorkDir/$artSrc' ]; then cp -r '$GuestLinuxWorkDir/$artSrc/.' '$outDir/'; elif [ -e '$GuestLinuxWorkDir/$artSrc' ]; then cp '$GuestLinuxWorkDir/$artSrc' '$outDir/'; else echo '[Invoke-RemoteBuild] WARNING: artifact source not found: $artSrc' >&2; fi"
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Linux build complete."
|
||||
return
|
||||
}
|
||||
|
||||
function Compress-BuildArtifact {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Path,
|
||||
[Parameter(Mandatory)]
|
||||
[string] $DestinationPath
|
||||
)
|
||||
$7zip = 'C:\Program Files\7-Zip\7z.exe'
|
||||
if (Test-Path $7zip) {
|
||||
Write-Host "[Compress-BuildArtifact] Using 7-Zip (multi-threaded)..."
|
||||
& $7zip a -mmt=on -mx1 $DestinationPath $Path 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "7-Zip compression failed (exit $LASTEXITCODE)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "[Compress-BuildArtifact] 7-Zip not found, using Compress-Archive (fallback)..."
|
||||
Compress-Archive -Path $Path -DestinationPath $DestinationPath -CompressionLevel Fastest -Force
|
||||
}
|
||||
}
|
||||
|
||||
$sessionOptions = New-CISessionOption
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Connecting to VM at $IPAddress..."
|
||||
|
||||
@@ -88,30 +289,33 @@ $session = New-PSSession `
|
||||
-ComputerName $IPAddress `
|
||||
-Credential $Credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-UseSSL `
|
||||
-Port 5986 `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
|
||||
try {
|
||||
# ── Ensure working directories exist on guest ────────────────────────
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir, $outputDir)
|
||||
foreach ($dir in @($workDir, (Split-Path $outputDir -Parent))) {
|
||||
if (-not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
}
|
||||
# Clean previous build if present
|
||||
if (Test-Path $workDir) {
|
||||
Remove-Item $workDir -Recurse -Force
|
||||
# Ensure artifact output parent exists
|
||||
$outParent = Split-Path $outputDir -Parent
|
||||
if (-not (Test-Path $outParent)) {
|
||||
New-Item -ItemType Directory -Path $outParent -Force | Out-Null
|
||||
}
|
||||
# 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
|
||||
} -ArgumentList $GuestWorkDir, $GuestArtifactZip
|
||||
|
||||
# ── Copy source tree: compress on host, transfer zip, expand in guest ──
|
||||
# ── Source preparation: Host clone (compress+transfer) or Guest clone (git) ──
|
||||
if ($hostCloneMode) {
|
||||
# Mode 1: Copy pre-cloned source from host to guest via WinRM zip
|
||||
# Single-file transfer over WinRM is far faster than recursive Copy-Item.
|
||||
$hostZip = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.zip'
|
||||
try {
|
||||
Write-Host "[Invoke-RemoteBuild] Compressing source on host..."
|
||||
Compress-Archive -Path "$HostSourceDir\*" -DestinationPath $hostZip -CompressionLevel Fastest -Force
|
||||
Compress-BuildArtifact -Path "$HostSourceDir\*" -DestinationPath $hostZip
|
||||
$zipSize = [math]::Round((Get-Item $hostZip).Length / 1MB, 1)
|
||||
Write-Host "[Invoke-RemoteBuild] Transferring archive to guest ($zipSize MB)..."
|
||||
$guestZip = 'C:\CI\src-transfer.zip'
|
||||
@@ -127,15 +331,82 @@ try {
|
||||
finally {
|
||||
Remove-Item $hostZip -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Mode 2 (§3.3): Clone repo directly in guest via git
|
||||
# PAT (if needed) read from Credential Manager inside session, never logged
|
||||
Write-Host "[Invoke-RemoteBuild] Cloning repository in guest (§3.3)..."
|
||||
|
||||
# Read PAT from host Credential Manager (§1.5: never in argv or logs)
|
||||
$gitPatUser = $null
|
||||
$gitPatPass = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($GiteaCredentialTarget)) {
|
||||
try {
|
||||
$stored = Get-StoredCredential -Target $GiteaCredentialTarget -ErrorAction Stop
|
||||
$gitPatUser = $stored.UserName
|
||||
$gitPatPass = $stored.GetNetworkCredential().Password
|
||||
Write-Host "[Invoke-RemoteBuild] PAT loaded from Credential Manager ($GiteaCredentialTarget)."
|
||||
} catch {
|
||||
Write-Warning "[Invoke-RemoteBuild] Could not read PAT from Credential Manager '$GiteaCredentialTarget' — proceeding without auth (public repo only)."
|
||||
}
|
||||
}
|
||||
|
||||
# Clone in guest via WinRM
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($cloneUrl, $branch, $commit, $workDir, $patUser, $patPass, $submodules)
|
||||
|
||||
Set-Location (Split-Path $workDir -Parent)
|
||||
$gitArgs = @('clone', '--depth', '1', '--branch', $branch)
|
||||
if ($submodules) { $gitArgs += '--recurse-submodules' }
|
||||
$gitArgs += @($cloneUrl, (Split-Path $workDir -Leaf))
|
||||
|
||||
# Disable interactive credential prompts — WinRM has no TTY
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
|
||||
# Build git config args:
|
||||
# - credential.helper= (empty) clears GCM and any system credential helper
|
||||
# - http.extraHeader injects Basic auth without credential helpers, temp files,
|
||||
# or modifying the clone URL (§1.5: token never in URL, argv display, or logs)
|
||||
$gitConfigArgs = @('-c', 'credential.helper=')
|
||||
if ($patUser -and $patPass) {
|
||||
$b64 = [Convert]::ToBase64String(
|
||||
[Text.Encoding]::UTF8.GetBytes("${patUser}:${patPass}"))
|
||||
$gitConfigArgs += @('-c', "http.extraHeader=Authorization: Basic $b64")
|
||||
}
|
||||
|
||||
$gitArgs = $gitConfigArgs + $gitArgs
|
||||
|
||||
Write-Host "[Guest] Cloning $cloneUrl (branch: $branch)..."
|
||||
& git @gitArgs 2>&1 | ForEach-Object { Write-Host "[Guest Clone] $_" }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git clone failed in guest (exit $LASTEXITCODE)"
|
||||
}
|
||||
|
||||
# Checkout specific commit if provided
|
||||
if ($commit) {
|
||||
Write-Host "[Guest] Checking out commit: $commit"
|
||||
Set-Location $workDir
|
||||
& git checkout $commit 2>&1 | ForEach-Object { Write-Host "[Guest Checkout] $_" }
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git checkout $commit failed in guest (exit $LASTEXITCODE)"
|
||||
}
|
||||
}
|
||||
} -ArgumentList $CloneUrl, $CloneBranch, $CloneCommit, $GuestWorkDir, $gitPatUser, $gitPatPass, $CloneSubmodules.IsPresent
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Guest clone complete."
|
||||
}
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────
|
||||
if ($BuildCommand -ne '') {
|
||||
# ── Custom build command (e.g. python, msbuild, cmake…) ──────────
|
||||
Write-Host "[Invoke-RemoteBuild] Running build command: $BuildCommand"
|
||||
$buildExit = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir, $cmd, $artifactZip, $artifactSrc)
|
||||
param($workDir, $cmd, $artifactZip, $artifactSrc, $useCache)
|
||||
# Unbuffered Python output — lines appear in real-time instead of being held in buffer
|
||||
$env:PYTHONUNBUFFERED = '1'
|
||||
if ($useCache) {
|
||||
$env:PIP_CACHE_DIR = '\\vmware-host\Shared Folders\pip-cache'
|
||||
}
|
||||
Set-Location $workDir
|
||||
# Stream each output line via Write-Host — WinRM forwards Write-Host in real-time
|
||||
& cmd /c "$cmd 2>&1" | ForEach-Object { Write-Host $_ }
|
||||
@@ -147,11 +418,19 @@ try {
|
||||
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
|
||||
}
|
||||
$srcPath = Join-Path $workDir $artifactSrc
|
||||
$7zip = 'C:\Program Files\7-Zip\7z.exe'
|
||||
if (Test-Path $7zip) {
|
||||
Write-Host "[Build] Compressing artifacts with 7-Zip..."
|
||||
& $7zip a -mmt=on -mx1 $artifactZip $srcPath 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ }
|
||||
if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" }
|
||||
}
|
||||
else {
|
||||
Compress-Archive -Path $srcPath -DestinationPath $artifactZip -Force
|
||||
}
|
||||
}
|
||||
|
||||
return $exit
|
||||
} -ArgumentList $GuestWorkDir, $BuildCommand, $GuestArtifactZip, $GuestArtifactSource
|
||||
} -ArgumentList $GuestWorkDir, $BuildCommand, $GuestArtifactZip, $GuestArtifactSource, $UseSharedCache.IsPresent
|
||||
|
||||
if ($buildExit -ne 0) {
|
||||
throw "Build command failed (exit $buildExit)"
|
||||
@@ -161,11 +440,14 @@ try {
|
||||
# ── Default: dotnet restore + dotnet build ────────────────────────
|
||||
Write-Host "[Invoke-RemoteBuild] Running dotnet restore..."
|
||||
$restoreExit = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir)
|
||||
param($workDir, $useCache)
|
||||
if ($useCache) {
|
||||
$env:NUGET_PACKAGES = '\\vmware-host\Shared Folders\nuget-cache'
|
||||
}
|
||||
Set-Location $workDir
|
||||
dotnet restore 2>&1 | ForEach-Object { Write-Host $_ }
|
||||
return $LASTEXITCODE
|
||||
} -ArgumentList $GuestWorkDir
|
||||
} -ArgumentList $GuestWorkDir, $UseSharedCache.IsPresent
|
||||
|
||||
if ($restoreExit -ne 0) {
|
||||
throw "dotnet restore failed (exit $restoreExit)"
|
||||
@@ -173,7 +455,10 @@ try {
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Running dotnet build (configuration: $Configuration)..."
|
||||
$buildExit = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir, $config, $artifactZip)
|
||||
param($workDir, $config, $artifactZip, $useCache)
|
||||
if ($useCache) {
|
||||
$env:NUGET_PACKAGES = '\\vmware-host\Shared Folders\nuget-cache'
|
||||
}
|
||||
Set-Location $workDir
|
||||
$outputDir = Join-Path $workDir 'bin\CIOutput'
|
||||
dotnet build --configuration $config --output $outputDir 2>&1 | ForEach-Object { Write-Host $_ }
|
||||
@@ -184,11 +469,19 @@ try {
|
||||
if (-not (Test-Path $archiveDir)) {
|
||||
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
|
||||
}
|
||||
$7zip = 'C:\Program Files\7-Zip\7z.exe'
|
||||
if (Test-Path $7zip) {
|
||||
Write-Host "[Build] Compressing output with 7-Zip..."
|
||||
& $7zip a -mmt=on -mx1 $artifactZip "$outputDir\*" 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ }
|
||||
if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" }
|
||||
}
|
||||
else {
|
||||
Compress-Archive -Path "$outputDir\*" -DestinationPath $artifactZip -Force
|
||||
}
|
||||
}
|
||||
|
||||
return $exit
|
||||
} -ArgumentList $GuestWorkDir, $Configuration, $GuestArtifactZip
|
||||
} -ArgumentList $GuestWorkDir, $Configuration, $GuestArtifactZip, $UseSharedCache.IsPresent
|
||||
|
||||
if ($buildExit -ne 0) {
|
||||
throw "dotnet build failed (exit $buildExit)"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Purges old CI artifacts, logs, and stale IP leases based on retention policy.
|
||||
|
||||
.DESCRIPTION
|
||||
Removes per-job subdirectories under ArtifactDir and LogDir whose last-write
|
||||
time is older than RetentionDays. If the monitored drive is below MinFreeGB,
|
||||
switches automatically to AggressiveRetentionDays to reclaim space faster.
|
||||
|
||||
Also removes stale IP lease files under F:\CI\State\ip-leases\ that are
|
||||
older than 12 hours (defensive cleanup for jobs that crashed without a finally).
|
||||
|
||||
Designed to run as a Windows Scheduled Task (see Register-CIScheduledTasks.ps1).
|
||||
Safe to run while CI jobs are active — only deletes directories whose
|
||||
last-write time predates the cutoff.
|
||||
|
||||
.PARAMETER ArtifactDir
|
||||
Directory containing per-job artifact subdirectories. Default: F:\CI\Artifacts
|
||||
|
||||
.PARAMETER LogDir
|
||||
Directory containing per-job log subdirectories. Default: F:\CI\Logs
|
||||
|
||||
.PARAMETER RetentionDays
|
||||
Normal retention threshold in days. Default: 30
|
||||
|
||||
.PARAMETER AggressiveRetentionDays
|
||||
Retention threshold used when drive free space drops below MinFreeGB. Default: 7
|
||||
|
||||
.PARAMETER MinFreeGB
|
||||
If drive free space is below this value, AggressiveRetentionDays is used instead
|
||||
of RetentionDays and a warning is emitted. Default: 50
|
||||
|
||||
.PARAMETER DriveLetter
|
||||
Single drive letter (without colon) to check for free space. Default: F
|
||||
|
||||
.EXAMPLE
|
||||
# Dry run — lists what would be deleted
|
||||
.\Invoke-RetentionPolicy.ps1 -WhatIf
|
||||
|
||||
# Live run
|
||||
.\Invoke-RetentionPolicy.ps1
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[string] $ArtifactDir = 'F:\CI\Artifacts',
|
||||
[string] $LogDir = 'F:\CI\Logs',
|
||||
|
||||
[ValidateRange(1, 365)]
|
||||
[int] $RetentionDays = 30,
|
||||
|
||||
[ValidateRange(1, 365)]
|
||||
[int] $AggressiveRetentionDays = 7,
|
||||
|
||||
[ValidateRange(1, 1000)]
|
||||
[int] $MinFreeGB = 50,
|
||||
|
||||
[ValidatePattern('^[A-Za-z]$')]
|
||||
[string] $DriveLetter = 'F'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Continue' # Don't abort on individual file errors
|
||||
|
||||
# ── Determine effective retention threshold ───────────────────────────────────
|
||||
$drive = Get-PSDrive -Name $DriveLetter -ErrorAction SilentlyContinue
|
||||
$freeGB = if ($drive) { [math]::Round($drive.Free / 1GB, 1) } else { [double]::MaxValue }
|
||||
|
||||
$aggressiveMode = $freeGB -lt $MinFreeGB
|
||||
$effectiveDays = if ($aggressiveMode) { $AggressiveRetentionDays } else { $RetentionDays }
|
||||
$cutoff = (Get-Date).AddDays(-$effectiveDays)
|
||||
|
||||
Write-Host "[RetentionPolicy] Drive ${DriveLetter}: free ${freeGB} GB"
|
||||
if ($aggressiveMode) {
|
||||
Write-Warning "[RetentionPolicy] Free space below ${MinFreeGB} GB — aggressive retention: ${AggressiveRetentionDays}d"
|
||||
} else {
|
||||
Write-Host "[RetentionPolicy] Normal retention: ${effectiveDays}d (cutoff: $($cutoff.ToString('yyyy-MM-dd')))"
|
||||
}
|
||||
|
||||
# ── Helper: purge old subdirectories under a base dir ─────────────────────────
|
||||
function Remove-OldJobDirs {
|
||||
param([string]$BaseDir, [string]$Label)
|
||||
|
||||
if (-not (Test-Path $BaseDir)) {
|
||||
Write-Host "[RetentionPolicy] $Label dir not found: $BaseDir — skipping."
|
||||
return
|
||||
}
|
||||
|
||||
$old = Get-ChildItem -Path $BaseDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt $cutoff }
|
||||
|
||||
if (-not @($old).Count) {
|
||||
Write-Host "[RetentionPolicy] ${Label}: nothing to purge (cutoff: $($cutoff.ToString('yyyy-MM-dd')))."
|
||||
return
|
||||
}
|
||||
|
||||
foreach ($dir in $old) {
|
||||
$agedays = [int]((Get-Date) - $dir.LastWriteTime).TotalDays
|
||||
if ($PSCmdlet.ShouldProcess($dir.FullName, "Remove $Label dir (age: ${agedays}d)")) {
|
||||
Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "[RetentionPolicy] Purged $Label (${agedays}d): $($dir.Name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Remove-OldJobDirs -BaseDir $ArtifactDir -Label 'artifact'
|
||||
Remove-OldJobDirs -BaseDir $LogDir -Label 'log'
|
||||
|
||||
# ── Stale IP lease cleanup (§2.1 defensive) ───────────────────────────────────
|
||||
# Lease files are normally deleted by Invoke-CIJob.ps1 finally block.
|
||||
# If the host crashed mid-job the lease file is never removed. Remove leases
|
||||
# older than 12 hours (well beyond any plausible build duration).
|
||||
$leasesDir = 'F:\CI\State\ip-leases'
|
||||
$leaseCutoff = (Get-Date).AddHours(-12)
|
||||
if (Test-Path $leasesDir) {
|
||||
$staleLeases = Get-ChildItem -Path $leasesDir -File -Filter '*.lease' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt $leaseCutoff }
|
||||
foreach ($lf in @($staleLeases)) {
|
||||
if ($PSCmdlet.ShouldProcess($lf.FullName, 'Remove stale IP lease')) {
|
||||
Remove-Item $lf.FullName -Force -ErrorAction SilentlyContinue
|
||||
Write-Host "[RetentionPolicy] Removed stale IP lease: $($lf.Name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
$freeGBAfter = if ($drive) { [math]::Round((Get-PSDrive -Name $DriveLetter -ErrorAction SilentlyContinue).Free / 1GB, 1) } else { $freeGB }
|
||||
$delta = [math]::Round($freeGBAfter - $freeGB, 1)
|
||||
Write-Host "[RetentionPolicy] Done. Drive ${DriveLetter}: free ${freeGBAfter} GB (delta: +${delta} GB)"
|
||||
@@ -0,0 +1,246 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Measures CI infrastructure phase timings: clone, start, IP acquire, WinRM ready, destroy.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates one or more ephemeral VMs from the template, times each phase, destroys
|
||||
them, then prints a summary table. Results are appended to F:\CI\Logs\benchmark.jsonl
|
||||
for long-term trend tracking (compare before/after §3.1 cache, §3.2 7-Zip, etc.).
|
||||
|
||||
Phases measured:
|
||||
clone — vmrun clone (linked clone from snapshot)
|
||||
start — vmrun start (until command returns)
|
||||
ip — vmrun getGuestIPAddress (polling until IP appears)
|
||||
winrm — TCP 5986 reachable (polling)
|
||||
destroy — stop + deleteVM + dir removal
|
||||
|
||||
No actual build is run. Phase 5 build timings come from invoke-ci.jsonl logs
|
||||
produced by Invoke-CIJob.ps1.
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Full path to the template VM's .vmx file.
|
||||
Default: F:\CI\Templates\WinBuild2025\WinBuild2025.vmx
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Snapshot name to clone from. Default: BaseClean
|
||||
|
||||
.PARAMETER CloneBaseDir
|
||||
Directory for ephemeral clone folders. Default: F:\CI\BuildVMs
|
||||
|
||||
.PARAMETER VmrunPath
|
||||
Path to vmrun.exe.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||
|
||||
.PARAMETER Iterations
|
||||
Number of clone→ready→destroy cycles to run. Multiple iterations reduce timing
|
||||
variance from cold-cache effects. Default: 1
|
||||
|
||||
.PARAMETER TimeoutSeconds
|
||||
Maximum seconds to wait for IP and WinRM per iteration. Default: 300
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Directory where benchmark.jsonl is appended. Default: F:\CI\Logs
|
||||
|
||||
.EXAMPLE
|
||||
# Single baseline run
|
||||
.\Measure-CIBenchmark.ps1
|
||||
|
||||
# 3 iterations for average (first may be slow due to cold host disk cache)
|
||||
.\Measure-CIBenchmark.ps1 -Iterations 3
|
||||
|
||||
# Custom snapshot
|
||||
.\Measure-CIBenchmark.ps1 -SnapshotName BaseClean_20260510
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $TemplatePath = 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx',
|
||||
[string] $SnapshotName = 'BaseClean',
|
||||
[string] $CloneBaseDir = 'F:\CI\BuildVMs',
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
||||
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $Iterations = 1,
|
||||
|
||||
[ValidateRange(60, 600)]
|
||||
[int] $TimeoutSeconds = 300,
|
||||
|
||||
[string] $OutputDir = 'F:\CI\Logs'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
||||
Resolve-VmrunPath -VmrunPath $VmrunPath | Out-Null
|
||||
|
||||
if (-not (Test-Path $TemplatePath -PathType Leaf)) {
|
||||
throw "Template VMX not found: $TemplatePath"
|
||||
}
|
||||
if (-not (Test-Path $CloneBaseDir)) {
|
||||
New-Item -ItemType Directory -Path $CloneBaseDir -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$benchmarkLog = Join-Path $OutputDir 'benchmark.jsonl'
|
||||
$results = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "=== CI Benchmark template: $(Split-Path $TemplatePath -Leaf) snapshot: $SnapshotName iterations: $Iterations ==="
|
||||
Write-Host ""
|
||||
|
||||
for ($i = 1; $i -le $Iterations; $i++) {
|
||||
Write-Host "--- Iteration $i / $Iterations ---"
|
||||
$runId = "bench-$(Get-Date -Format 'yyyyMMdd-HHmmss')-$i"
|
||||
$cloneVmx = $null
|
||||
$cloneDir = $null
|
||||
|
||||
$t = [ordered]@{
|
||||
clone = $null
|
||||
start = $null
|
||||
ip = $null
|
||||
winrm = $null
|
||||
destroy = $null
|
||||
vmIP = $null
|
||||
error = $null
|
||||
}
|
||||
|
||||
try {
|
||||
# ── Phase: clone ──────────────────────────────────────────────────────
|
||||
$cloneName = "Clone_${runId}"
|
||||
$cloneDir = Join-Path $CloneBaseDir $cloneName
|
||||
$cloneVmx = Join-Path $cloneDir "$cloneName.vmx"
|
||||
|
||||
Write-Host "[bench] Cloning..."
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
$r = Invoke-Vmrun -VmrunPath $VmrunPath -Operation clone `
|
||||
-Arguments @($TemplatePath, $cloneVmx, 'linked', '-snapshot', $SnapshotName) `
|
||||
-ThrowOnError
|
||||
$t.clone = [math]::Round($sw.Elapsed.TotalSeconds, 2)
|
||||
Write-Host "[bench] clone: $($t.clone)s"
|
||||
|
||||
# ── Phase: start ──────────────────────────────────────────────────────
|
||||
Write-Host "[bench] Starting VM..."
|
||||
$sw.Restart()
|
||||
Invoke-Vmrun -VmrunPath $VmrunPath -Operation start `
|
||||
-Arguments @($cloneVmx, 'nogui') -ThrowOnError | Out-Null
|
||||
$t.start = [math]::Round($sw.Elapsed.TotalSeconds, 2)
|
||||
Write-Host "[bench] start: $($t.start)s"
|
||||
|
||||
# ── Phase: IP acquire (poll getGuestIPAddress) ────────────────────────
|
||||
Write-Host "[bench] Waiting for guest IP..."
|
||||
$sw.Restart()
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$vmIP = $null
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$ipResult = Invoke-Vmrun -VmrunPath $VmrunPath -Operation getGuestIPAddress `
|
||||
-Arguments @($cloneVmx)
|
||||
if ($ipResult.ExitCode -eq 0) {
|
||||
$candidate = "$($ipResult.Output)".Trim()
|
||||
if ($candidate -match '^\d+\.\d+\.\d+\.\d+$') {
|
||||
$vmIP = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if (-not $vmIP) { throw "Timed out waiting for guest IP after $TimeoutSeconds s" }
|
||||
$t.ip = [math]::Round($sw.Elapsed.TotalSeconds, 2)
|
||||
$t.vmIP = $vmIP
|
||||
Write-Host "[bench] ip ($vmIP): $($t.ip)s"
|
||||
|
||||
# ── Phase: WinRM ready (poll TCP 5986) ────────────────────────────────
|
||||
Write-Host "[bench] Waiting for WinRM (TCP 5986)..."
|
||||
$sw.Restart()
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$winrmUp = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$winrmUp = Test-NetConnection -ComputerName $vmIP -Port 5986 `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue `
|
||||
-ErrorAction SilentlyContinue
|
||||
if ($winrmUp) { break }
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
if (-not $winrmUp) { throw "Timed out waiting for WinRM on $vmIP`:5986 after $TimeoutSeconds s" }
|
||||
$t.winrm = [math]::Round($sw.Elapsed.TotalSeconds, 2)
|
||||
Write-Host "[bench] winrm: $($t.winrm)s"
|
||||
}
|
||||
catch {
|
||||
$t.error = "$_"
|
||||
Write-Warning "[bench] Iteration $i failed: $_"
|
||||
}
|
||||
finally {
|
||||
# ── Phase: destroy ────────────────────────────────────────────────────
|
||||
if ($cloneVmx -and (Test-Path $cloneVmx -PathType Leaf)) {
|
||||
Write-Host "[bench] Destroying clone..."
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
& (Join-Path $PSScriptRoot 'Remove-BuildVM.ps1') `
|
||||
-VMPath $cloneVmx -VmrunPath $VmrunPath -GracefulTimeoutSeconds 10 `
|
||||
-ErrorAction SilentlyContinue
|
||||
$t.destroy = [math]::Round($sw.Elapsed.TotalSeconds, 2)
|
||||
Write-Host "[bench] destroy: $($t.destroy)s"
|
||||
} elseif ($cloneDir -and (Test-Path $cloneDir)) {
|
||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$totalBootSec = if ($t.ip -and $t.winrm) {
|
||||
[math]::Round($t.clone + $t.start + $t.ip + $t.winrm, 2)
|
||||
} else { $null }
|
||||
|
||||
$result = [pscustomobject]@{
|
||||
ts = (Get-Date -Format 'o')
|
||||
runId = $runId
|
||||
iteration = $i
|
||||
template = Split-Path $TemplatePath -Leaf
|
||||
snapshot = $SnapshotName
|
||||
cloneSec = $t.clone
|
||||
startSec = $t.start
|
||||
ipSec = $t.ip
|
||||
winrmSec = $t.winrm
|
||||
destroySec = $t.destroy
|
||||
totalBootSec = $totalBootSec
|
||||
vmIP = $t.vmIP
|
||||
error = $t.error
|
||||
}
|
||||
$results.Add($result)
|
||||
|
||||
# Append to JSONL log
|
||||
try {
|
||||
$result | ConvertTo-Json -Compress -Depth 3 |
|
||||
Add-Content -Path $benchmarkLog -Encoding UTF8 -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Warning "[bench] Could not write benchmark.jsonl: $_"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
# ── Summary table ─────────────────────────────────────────────────────────────
|
||||
Write-Host "=== Results ==="
|
||||
$results | Format-Table @(
|
||||
@{ L = 'Iter'; E = { $_.iteration } }
|
||||
@{ L = 'Clone(s)'; E = { $_.cloneSec } }
|
||||
@{ L = 'Start(s)'; E = { $_.startSec } }
|
||||
@{ L = 'IP(s)'; E = { $_.ipSec } }
|
||||
@{ L = 'WinRM(s)'; E = { $_.winrmSec } }
|
||||
@{ L = 'Destroy(s)'; E = { $_.destroySec } }
|
||||
@{ L = 'Boot total'; E = { $_.totalBootSec } }
|
||||
@{ L = 'IP'; E = { $_.vmIP } }
|
||||
@{ L = 'Error'; E = { $_.error } }
|
||||
) -AutoSize
|
||||
|
||||
if ($Iterations -gt 1) {
|
||||
$ok = @($results | Where-Object { -not $_.error })
|
||||
if ($ok.Count -gt 0) {
|
||||
$avgBoot = [math]::Round(($ok | ForEach-Object { $_.totalBootSec } |
|
||||
Measure-Object -Average).Average, 2)
|
||||
Write-Host "Average boot-to-WinRM ($($ok.Count) successful runs): $avgBoot s"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Results appended to: $benchmarkLog"
|
||||
|
||||
+7
-18
@@ -58,10 +58,9 @@ param(
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- Validate vmrun.exe ---
|
||||
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
|
||||
throw "vmrun.exe not found at: $VmrunPath`nInstall VMware Workstation Pro and update -VmrunPath if needed."
|
||||
}
|
||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
||||
|
||||
Resolve-VmrunPath -VmrunPath $VmrunPath | Out-Null
|
||||
|
||||
# --- Build clone path ---
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
@@ -81,27 +80,17 @@ Write-Host " Clone VMX : $cloneVmx"
|
||||
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# --- Run vmrun clone ---
|
||||
$vmrunArgs = @(
|
||||
'-T', 'ws',
|
||||
'clone',
|
||||
$TemplatePath,
|
||||
$cloneVmx,
|
||||
'linked',
|
||||
'-snapshot', $SnapshotName
|
||||
)
|
||||
|
||||
$result = & $VmrunPath @vmrunArgs 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
$cloneResult = Invoke-Vmrun -VmrunPath $VmrunPath -Operation clone `
|
||||
-Arguments @($TemplatePath, $cloneVmx, 'linked', '-snapshot', $SnapshotName)
|
||||
|
||||
$sw.Stop()
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
if ($cloneResult.ExitCode -ne 0) {
|
||||
# Clean up partial clone directory if it was created
|
||||
if (Test-Path $cloneDir) {
|
||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
throw "vmrun clone failed (exit $exitCode).`nOutput: $result"
|
||||
throw "vmrun clone failed (exit $($cloneResult.ExitCode)).`nOutput: $($cloneResult.Output)"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $cloneVmx -PathType Leaf)) {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#Requires -Version 5.1
|
||||
#Requires -RunAsAdministrator
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Registers Windows Scheduled Tasks for CI system maintenance.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates or updates four tasks under the \CI\ task folder:
|
||||
|
||||
CI-CleanupOrphans — Runs Cleanup-OrphanedBuildVMs.ps1 every 6 hours
|
||||
and at host startup. Destroys stale build VMs
|
||||
(those older than -MaxAgeHours, default 4) that
|
||||
were not cleaned up after a crash or timeout.
|
||||
|
||||
CI-RetentionPolicy — Runs Invoke-RetentionPolicy.ps1 daily at 3:00 AM
|
||||
and at host startup (with a 15-min random delay).
|
||||
Purges old artifact and log directories per the
|
||||
configured retention window.
|
||||
|
||||
CI-DiskSpaceAlert — Runs Watch-DiskSpace.ps1 every 15 minutes.
|
||||
Writes an Event Log warning and optional webhook
|
||||
when CI drive free space falls below 50 GB.
|
||||
|
||||
CI-RunnerHealth — Runs Watch-RunnerHealth.ps1 every 15 minutes.
|
||||
Auto-restarts act_runner if stopped; rate-limited
|
||||
to 3 restarts per hour before requiring manual fix.
|
||||
|
||||
All tasks run as SYSTEM with highest privilege. Idempotent: safe to re-run
|
||||
after updating the scripts — existing tasks are overwritten (-Force).
|
||||
|
||||
.PARAMETER ScriptRoot
|
||||
Directory containing the CI scripts. Default: N:\Code\Workspace\Local-CI-CD-System\scripts
|
||||
|
||||
.PARAMETER MaxAgeHours
|
||||
Passed to Cleanup-OrphanedBuildVMs.ps1. VMs older than this are treated as
|
||||
orphaned. Must exceed the longest expected build duration. Default: 4
|
||||
|
||||
.EXAMPLE
|
||||
# Register (or update) tasks — run from an elevated PowerShell session
|
||||
.\Register-CIScheduledTasks.ps1
|
||||
|
||||
# Preview what would be registered
|
||||
.\Register-CIScheduledTasks.ps1 -WhatIf
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[string] $ScriptRoot = 'N:\Code\Workspace\Local-CI-CD-System\scripts',
|
||||
|
||||
[ValidateRange(1, 168)]
|
||||
[int] $MaxAgeHours = 4
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$taskPath = '\CI\'
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
|
||||
|
||||
# Ensure task folder exists (Register-ScheduledTask creates it, but be explicit)
|
||||
$schedulerService = New-Object -ComObject 'Schedule.Service'
|
||||
$schedulerService.Connect()
|
||||
$rootFolder = $schedulerService.GetFolder('\')
|
||||
try { $rootFolder.GetFolder('CI') | Out-Null }
|
||||
catch { $rootFolder.CreateFolder('CI') | Out-Null }
|
||||
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($schedulerService) | Out-Null
|
||||
|
||||
# ── Task 1: CI-CleanupOrphans ─────────────────────────────────────────────────
|
||||
$cleanupScript = Join-Path $ScriptRoot 'Cleanup-OrphanedBuildVMs.ps1'
|
||||
if (-not (Test-Path $cleanupScript -PathType Leaf)) {
|
||||
Write-Warning "Script not found — skipping CI-CleanupOrphans: $cleanupScript"
|
||||
} else {
|
||||
$cleanupAction = New-ScheduledTaskAction `
|
||||
-Execute $psExe `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$cleanupScript`" -MaxAgeHours $MaxAgeHours"
|
||||
|
||||
# Repeat every 6 hours indefinitely (Once trigger + RepetitionInterval)
|
||||
$repeat6h = New-ScheduledTaskTrigger -Once -At '00:00' `
|
||||
-RepetitionInterval (New-TimeSpan -Hours 6)
|
||||
$atStartup = New-ScheduledTaskTrigger -AtStartup
|
||||
|
||||
$cleanupSettings = New-ScheduledTaskSettingsSet `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Hours 1) `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-StartWhenAvailable
|
||||
|
||||
if ($PSCmdlet.ShouldProcess('CI-CleanupOrphans', 'Register/update scheduled task')) {
|
||||
Register-ScheduledTask `
|
||||
-TaskName 'CI-CleanupOrphans' `
|
||||
-TaskPath $taskPath `
|
||||
-Action $cleanupAction `
|
||||
-Trigger @($repeat6h, $atStartup) `
|
||||
-Principal $principal `
|
||||
-Settings $cleanupSettings `
|
||||
-Description "Destroys orphaned CI build VMs older than ${MaxAgeHours}h (Local CI/CD System)" `
|
||||
-Force | Out-Null
|
||||
Write-Host "[Register] CI-CleanupOrphans registered (every 6h + at startup, -MaxAgeHours $MaxAgeHours)." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ── Task 2: CI-RetentionPolicy ────────────────────────────────────────────────
|
||||
$retentionScript = Join-Path $ScriptRoot 'Invoke-RetentionPolicy.ps1'
|
||||
if (-not (Test-Path $retentionScript -PathType Leaf)) {
|
||||
Write-Warning "Script not found — skipping CI-RetentionPolicy: $retentionScript"
|
||||
} else {
|
||||
$retentionAction = New-ScheduledTaskAction `
|
||||
-Execute $psExe `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$retentionScript`""
|
||||
|
||||
$daily3am = New-ScheduledTaskTrigger -Daily -At '03:00' `
|
||||
-RandomDelay (New-TimeSpan -Minutes 30)
|
||||
$atStartup2 = New-ScheduledTaskTrigger -AtStartup `
|
||||
-RandomDelay (New-TimeSpan -Minutes 15)
|
||||
|
||||
$retentionSettings = New-ScheduledTaskSettingsSet `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Hours 1) `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-StartWhenAvailable
|
||||
|
||||
if ($PSCmdlet.ShouldProcess('CI-RetentionPolicy', 'Register/update scheduled task')) {
|
||||
Register-ScheduledTask `
|
||||
-TaskName 'CI-RetentionPolicy' `
|
||||
-TaskPath $taskPath `
|
||||
-Action $retentionAction `
|
||||
-Trigger @($daily3am, $atStartup2) `
|
||||
-Principal $principal `
|
||||
-Settings $retentionSettings `
|
||||
-Description 'Purges old CI artifacts, logs, and stale IP leases (Local CI/CD System)' `
|
||||
-Force | Out-Null
|
||||
Write-Host "[Register] CI-RetentionPolicy registered (daily 03:00 + at startup)." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ── Task 3: CI-DiskSpaceAlert ─────────────────────────────────────────────────
|
||||
$diskScript = Join-Path $ScriptRoot 'Watch-DiskSpace.ps1'
|
||||
if (-not (Test-Path $diskScript -PathType Leaf)) {
|
||||
Write-Warning "Script not found — skipping CI-DiskSpaceAlert: $diskScript"
|
||||
} else {
|
||||
$diskAction = New-ScheduledTaskAction `
|
||||
-Execute $psExe `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$diskScript`""
|
||||
|
||||
# Every 15 minutes indefinitely
|
||||
$disk15min = New-ScheduledTaskTrigger -Once -At '00:00' `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes 15)
|
||||
|
||||
$diskSettings = New-ScheduledTaskSettingsSet `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-StartWhenAvailable
|
||||
|
||||
if ($PSCmdlet.ShouldProcess('CI-DiskSpaceAlert', 'Register/update scheduled task')) {
|
||||
Register-ScheduledTask `
|
||||
-TaskName 'CI-DiskSpaceAlert' `
|
||||
-TaskPath $taskPath `
|
||||
-Action $diskAction `
|
||||
-Trigger $disk15min `
|
||||
-Principal $principal `
|
||||
-Settings $diskSettings `
|
||||
-Description 'Alerts via Event Log when CI drive free space drops below 50 GB (Local CI/CD System)' `
|
||||
-Force | Out-Null
|
||||
Write-Host "[Register] CI-DiskSpaceAlert registered (every 15 min)." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ── Task 4: CI-RunnerHealth ───────────────────────────────────────────────────
|
||||
$healthScript = Join-Path $ScriptRoot 'Watch-RunnerHealth.ps1'
|
||||
if (-not (Test-Path $healthScript -PathType Leaf)) {
|
||||
Write-Warning "Script not found — skipping CI-RunnerHealth: $healthScript"
|
||||
} else {
|
||||
$healthAction = New-ScheduledTaskAction `
|
||||
-Execute $psExe `
|
||||
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$healthScript`""
|
||||
|
||||
# Every 15 minutes indefinitely
|
||||
$health15min = New-ScheduledTaskTrigger -Once -At '00:00' `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes 15)
|
||||
|
||||
$healthSettings = New-ScheduledTaskSettingsSet `
|
||||
-ExecutionTimeLimit (New-TimeSpan -Minutes 5) `
|
||||
-MultipleInstances IgnoreNew `
|
||||
-StartWhenAvailable
|
||||
|
||||
if ($PSCmdlet.ShouldProcess('CI-RunnerHealth', 'Register/update scheduled task')) {
|
||||
Register-ScheduledTask `
|
||||
-TaskName 'CI-RunnerHealth' `
|
||||
-TaskPath $taskPath `
|
||||
-Action $healthAction `
|
||||
-Trigger $health15min `
|
||||
-Principal $principal `
|
||||
-Settings $healthSettings `
|
||||
-Description 'Auto-restarts act_runner service if stopped; rate-limited to 3 restarts/h (Local CI/CD System)' `
|
||||
-Force | Out-Null
|
||||
Write-Host "[Register] CI-RunnerHealth registered (every 15 min, max 3 restarts/h)." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "`n[Register] Done. Verify with: Get-ScheduledTask -TaskPath '\CI\' | Format-Table TaskName, State" -ForegroundColor Cyan
|
||||
@@ -25,14 +25,14 @@
|
||||
.EXAMPLE
|
||||
.\Remove-BuildVM.ps1 -VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $VMPath,
|
||||
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
||||
|
||||
[ValidateRange(5, 60)]
|
||||
[ValidateRange(1, 60)]
|
||||
[int] $GracefulTimeoutSeconds = 15
|
||||
)
|
||||
|
||||
@@ -47,12 +47,14 @@ Write-Host "[Remove-BuildVM] Destroying VM: $VMPath"
|
||||
if (-not (Test-Path $VMPath -PathType Leaf)) {
|
||||
Write-Warning "[Remove-BuildVM] VMX file not found — nothing to remove: $VMPath"
|
||||
# Still attempt to remove the directory in case of partial clone
|
||||
if (Test-Path $cloneDir) {
|
||||
if ((Test-Path $cloneDir) -and $PSCmdlet.ShouldProcess($cloneDir, 'Remove partial clone directory')) {
|
||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $PSCmdlet.ShouldProcess($VMPath, 'Stop and delete build VM')) { return }
|
||||
|
||||
# ── Step 2: Graceful stop ─────────────────────────────────────────────────────
|
||||
Write-Host "[Remove-BuildVM] Sending soft stop..."
|
||||
& $VmrunPath -T ws stop $VMPath soft 2>&1 | Out-Null
|
||||
@@ -72,16 +74,37 @@ $stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1
|
||||
if ($stateOutput -match 'running') {
|
||||
Write-Host "[Remove-BuildVM] Graceful stop timed out — forcing hard stop..."
|
||||
& $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..."
|
||||
$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1
|
||||
$deleteExit = $LASTEXITCODE
|
||||
$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
|
||||
$deleteExit = $LASTEXITCODE
|
||||
if ($deleteExit -eq 0) { break }
|
||||
}
|
||||
|
||||
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."
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Configures VMware shared folder entries in the CI template VMX for build cache sharing.
|
||||
|
||||
.DESCRIPTION
|
||||
Adds two shared folder mappings to the template VM's .vmx file:
|
||||
nuget-cache host: F:\CI\Cache\NuGet guest: \\vmware-host\Shared Folders\nuget-cache
|
||||
pip-cache host: F:\CI\Cache\pip guest: \\vmware-host\Shared Folders\pip-cache
|
||||
|
||||
These UNC paths are used by Invoke-RemoteBuild.ps1 -UseSharedCache to redirect
|
||||
dotnet's NuGet package store and pip's download cache to persistent host-side
|
||||
directories, avoiding repeat downloads across builds.
|
||||
|
||||
The script is idempotent: existing sharedFolder.* lines are removed before
|
||||
the new block is written. A .bak copy of the original VMX is kept alongside it.
|
||||
|
||||
Prerequisites:
|
||||
- Template VM must NOT be running when this script runs.
|
||||
- VMware Tools (HGFS driver) must be installed in the template guest.
|
||||
- Run once; all subsequent linked clones inherit the shared folder config.
|
||||
|
||||
After running: use Invoke-RemoteBuild.ps1 -UseSharedCache on builds that
|
||||
target VMs cloned from this updated template.
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Full path to the template VM's .vmx file.
|
||||
Default: F:\CI\Templates\WinBuild2025\WinBuild2025.vmx
|
||||
|
||||
.PARAMETER NuGetCacheDir
|
||||
Host-side path for the NuGet package cache directory.
|
||||
Default: F:\CI\Cache\NuGet
|
||||
|
||||
.PARAMETER PipCacheDir
|
||||
Host-side path for the pip download cache directory.
|
||||
Default: F:\CI\Cache\pip
|
||||
|
||||
.EXAMPLE
|
||||
# Configure template (run from elevated PowerShell — template must be stopped)
|
||||
.\Set-TemplateSharedFolders.ps1
|
||||
|
||||
# Preview changes without modifying files
|
||||
.\Set-TemplateSharedFolders.ps1 -WhatIf
|
||||
#>
|
||||
[CmdletBinding(SupportsShouldProcess)]
|
||||
param(
|
||||
[string] $TemplatePath = 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx',
|
||||
[string] $NuGetCacheDir = 'F:\CI\Cache\NuGet',
|
||||
[string] $PipCacheDir = 'F:\CI\Cache\pip'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $TemplatePath -PathType Leaf)) {
|
||||
throw "Template VMX not found: $TemplatePath"
|
||||
}
|
||||
|
||||
# ── Step 1: Ensure host cache directories exist ───────────────────────────────
|
||||
foreach ($dir in @($NuGetCacheDir, $PipCacheDir)) {
|
||||
if (-not (Test-Path $dir)) {
|
||||
if ($PSCmdlet.ShouldProcess($dir, 'Create host cache directory')) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
Write-Host "[SharedFolders] Created host cache dir: $dir"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[SharedFolders] Host cache dir exists: $dir"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 2: Read VMX, strip existing sharedFolder + HGFS lines ───────────────
|
||||
$lines = [System.IO.File]::ReadAllLines($TemplatePath, [System.Text.Encoding]::UTF8)
|
||||
$stripped = $lines | Where-Object {
|
||||
$_ -notmatch '^sharedFolder' -and
|
||||
$_ -notmatch '^isolation\.tools\.hgfs\.disable'
|
||||
}
|
||||
|
||||
# ── Step 3: Build replacement sharedFolder block ──────────────────────────────
|
||||
# VMX hostPath requires double-escaped backslashes
|
||||
$nugetEsc = $NuGetCacheDir.Replace('\', '\\')
|
||||
$pipEsc = $PipCacheDir.Replace('\', '\\')
|
||||
|
||||
$sharedBlock = @(
|
||||
'sharedFolder.maxNum = "2"'
|
||||
'sharedFolder0.present = "TRUE"'
|
||||
'sharedFolder0.enabled = "TRUE"'
|
||||
'sharedFolder0.readAccess = "TRUE"'
|
||||
'sharedFolder0.writeAccess = "TRUE"'
|
||||
"sharedFolder0.hostPath = `"$nugetEsc`""
|
||||
'sharedFolder0.guestName = "nuget-cache"'
|
||||
'sharedFolder0.expiration = "never"'
|
||||
'sharedFolder1.present = "TRUE"'
|
||||
'sharedFolder1.enabled = "TRUE"'
|
||||
'sharedFolder1.readAccess = "TRUE"'
|
||||
'sharedFolder1.writeAccess = "TRUE"'
|
||||
"sharedFolder1.hostPath = `"$pipEsc`""
|
||||
'sharedFolder1.guestName = "pip-cache"'
|
||||
'sharedFolder1.expiration = "never"'
|
||||
'isolation.tools.hgfs.disable = "FALSE"'
|
||||
)
|
||||
|
||||
$newLines = @($stripped) + $sharedBlock
|
||||
|
||||
# ── Step 4: Backup and write VMX ─────────────────────────────────────────────
|
||||
if ($PSCmdlet.ShouldProcess($TemplatePath, 'Write updated VMX with shared folder entries')) {
|
||||
$backupPath = "$TemplatePath.bak"
|
||||
Copy-Item $TemplatePath $backupPath -Force
|
||||
Write-Host "[SharedFolders] Original backed up: $backupPath"
|
||||
|
||||
[System.IO.File]::WriteAllLines($TemplatePath, $newLines, [System.Text.Encoding]::UTF8)
|
||||
|
||||
Write-Host "[SharedFolders] VMX updated: $TemplatePath"
|
||||
Write-Host "[SharedFolders] Mappings:"
|
||||
Write-Host " nuget-cache $NuGetCacheDir -> \\vmware-host\Shared Folders\nuget-cache"
|
||||
Write-Host " pip-cache $PipCacheDir -> \\vmware-host\Shared Folders\pip-cache"
|
||||
Write-Host "[SharedFolders] Next: run Invoke-RemoteBuild.ps1 -UseSharedCache to activate."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
E2E test for §3.3 In-VM Git Clone feature.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs nsis-plugin-nsinnounp build twice:
|
||||
1. Without -UseGitClone (baseline: host-side zip-transfer)
|
||||
2. With -UseGitClone (target: guest-side git clone)
|
||||
|
||||
Measures elapsed time, artifact size, and logs comparison.
|
||||
Prerequisite: Template VM must have Git for Windows installed (§6.6).
|
||||
|
||||
.PARAMETER BaselineJobId
|
||||
Job ID for baseline run (host-side clone).
|
||||
Default: e2e-baseline-<yyyyMMdd_HHmmss>
|
||||
|
||||
.PARAMETER GuestCloneJobId
|
||||
Job ID for guest-side clone run.
|
||||
Default: e2e-guestclone-<yyyyMMdd_HHmmss>
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch to build. Default: main
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Path to template VMX. Default: env:GITEA_CI_TEMPLATE_PATH or
|
||||
F:\CI\Templates\WinBuild2025\WinBuild2025.vmx
|
||||
|
||||
.PARAMETER GuestRepoUrl
|
||||
HTTP(S) URL for guest-side clone. Must be reachable from inside the VM.
|
||||
Default: https://gitea.emulab.it/Simone/nsis-plugin-nsinnounp.git
|
||||
NOTE: ssh://gitea-ci alias works only on the host (SSH config), not inside the VM.
|
||||
|
||||
.PARAMETER SkipBaseline
|
||||
If set, skip baseline run and only run with -UseGitClone.
|
||||
|
||||
.PARAMETER SkipGuestClone
|
||||
If set, skip guest-clone run and only run baseline.
|
||||
|
||||
.EXAMPLE
|
||||
# Full comparison (both runs):
|
||||
.\Test-E2E-Section3.3.ps1
|
||||
|
||||
# Only test guest-clone mode (assume baseline already exists):
|
||||
.\Test-E2E-Section3.3.ps1 -SkipBaseline
|
||||
|
||||
# Only test baseline:
|
||||
.\Test-E2E-Section3.3.ps1 -SkipGuestClone
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $BaselineJobId = "e2e-baseline-$(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||
[string] $GuestCloneJobId = "e2e-guestclone-$(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||
[string] $Branch = 'main',
|
||||
[string] $TemplatePath = $(
|
||||
if ($env:GITEA_CI_TEMPLATE_PATH) { $env:GITEA_CI_TEMPLATE_PATH }
|
||||
else { 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' }
|
||||
),
|
||||
# URL for host-side clone (SSH alias works on host)
|
||||
[string] $HostRepoUrl = 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git',
|
||||
# URL for guest-side clone (must be HTTP reachable from inside VM — SSH alias not available in guest)
|
||||
[string] $GuestRepoUrl = 'https://gitea.emulab.it/Simone/nsis-plugin-nsinnounp.git',
|
||||
[switch] $SkipBaseline,
|
||||
[switch] $SkipGuestClone
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
|
||||
$orchestrator = Join-Path $scriptDir 'Invoke-CIJob.ps1'
|
||||
|
||||
# Common parameters (RepoUrl set per-mode below)
|
||||
$commonParams = @{
|
||||
Branch = $Branch
|
||||
TemplatePath = $TemplatePath
|
||||
Submodules = $true
|
||||
BuildCommand = 'python build_plugin.py --final --dist-dir dist'
|
||||
GuestArtifactSource = 'dist'
|
||||
GuestCredentialTarget = 'BuildVMGuest'
|
||||
}
|
||||
|
||||
function Format-Elapsed {
|
||||
param([TimeSpan] $ts)
|
||||
if ($ts.TotalHours -ge 1) {
|
||||
return $ts.ToString('hh\:mm\:ss')
|
||||
} else {
|
||||
return $ts.ToString('mm\:ss\.ff')
|
||||
}
|
||||
}
|
||||
|
||||
function Run-Test {
|
||||
param(
|
||||
[string] $JobId,
|
||||
[string] $Mode,
|
||||
[switch] $UseGitClone,
|
||||
[hashtable] $CommonParams
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "────────────────────────────────────────────────────────" -ForegroundColor Cyan
|
||||
Write-Host " $Mode" -ForegroundColor Cyan
|
||||
Write-Host "────────────────────────────────────────────────────────" -ForegroundColor Cyan
|
||||
Write-Host " JobId : $JobId"
|
||||
Write-Host " Branch : $Branch"
|
||||
Write-Host " Mode : $(if ($UseGitClone) { 'Guest-side clone (-UseGitClone)' } else { 'Host-side zip-transfer (baseline)' })"
|
||||
Write-Host ""
|
||||
|
||||
$params = $CommonParams.Clone()
|
||||
$params['JobId'] = $JobId
|
||||
if ($UseGitClone) {
|
||||
$params['UseGitClone'] = $true
|
||||
$params['RepoUrl'] = $script:GuestRepoUrl # HTTP URL reachable from inside VM
|
||||
} else {
|
||||
$params['RepoUrl'] = $script:HostRepoUrl # SSH alias — works on host
|
||||
}
|
||||
|
||||
$startTime = Get-Date
|
||||
$exitCode = 0
|
||||
|
||||
try {
|
||||
# Pipe to Out-Host so orchestrator output doesn't pollute this function's pipeline
|
||||
& $orchestrator @params | Out-Host
|
||||
$exitCode = $LASTEXITCODE
|
||||
}
|
||||
catch {
|
||||
Write-Host "[Test] Orchestrator threw: $_" -ForegroundColor Red
|
||||
$exitCode = 1
|
||||
}
|
||||
|
||||
$elapsed = (Get-Date) - $startTime
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Host "[Test] FAIL — exit code $exitCode after $(Format-Elapsed $elapsed)" -ForegroundColor Red
|
||||
return @{ Success = $false; Elapsed = $elapsed; JobId = $JobId; Mode = $Mode }
|
||||
}
|
||||
|
||||
$artifactDir = "F:\CI\Artifacts\$JobId"
|
||||
$artifactZip = Join-Path $artifactDir 'artifacts.zip'
|
||||
|
||||
if (-not (Test-Path $artifactZip)) {
|
||||
Write-Host "[Test] FAIL — artifact not found: $artifactZip" -ForegroundColor Red
|
||||
return @{ Success = $false; Elapsed = $elapsed; JobId = $JobId; Mode = $Mode }
|
||||
}
|
||||
|
||||
$zipSize = [math]::Round((Get-Item $artifactZip).Length / 1MB, 2)
|
||||
Write-Host "[Test] PASS — $(Format-Elapsed $elapsed)" -ForegroundColor Green
|
||||
Write-Host " Artifact: $zipSize MB"
|
||||
Write-Host " Logs : $artifactDir"
|
||||
|
||||
return @{ Success = $true; Elapsed = $elapsed; Size = $zipSize; JobId = $JobId; Mode = $Mode }
|
||||
}
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Test runs
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "╔════════════════════════════════════════════════════════╗" -ForegroundColor Yellow
|
||||
Write-Host "║ §3.3 E2E Test: In-VM Git Clone vs Host-side Zip ║" -ForegroundColor Yellow
|
||||
Write-Host "╚════════════════════════════════════════════════════════╝" -ForegroundColor Yellow
|
||||
|
||||
$baselineResult = $null
|
||||
$guestCloneResult = $null
|
||||
|
||||
if (-not $SkipBaseline) {
|
||||
$baselineResult = Run-Test -JobId $BaselineJobId -Mode "BASELINE (host-side zip)" -CommonParams $commonParams
|
||||
}
|
||||
|
||||
if (-not $SkipGuestClone) {
|
||||
$guestCloneResult = Run-Test -JobId $GuestCloneJobId -Mode "GUEST CLONE (-UseGitClone)" -UseGitClone -CommonParams $commonParams
|
||||
}
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
Write-Host " SUMMARY" -ForegroundColor Cyan
|
||||
Write-Host "════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
|
||||
if ($baselineResult -and $guestCloneResult) {
|
||||
if ($baselineResult.Success -and $guestCloneResult.Success) {
|
||||
$baselineTime = $baselineResult.Elapsed.TotalSeconds
|
||||
$guestTime = $guestCloneResult.Elapsed.TotalSeconds
|
||||
$diff = $baselineTime - $guestTime
|
||||
$pctChange = [math]::Round(($diff / $baselineTime) * 100, 1)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Baseline (host-side): $(Format-Elapsed $baselineResult.Elapsed) / $($baselineResult.Size) MB"
|
||||
Write-Host "Guest clone (-UseGit): $(Format-Elapsed $guestCloneResult.Elapsed) / $($guestCloneResult.Size) MB"
|
||||
Write-Host ""
|
||||
Write-Host "Difference: $([math]::Round($diff, 1)) sec ($pctChange%)" -ForegroundColor $(if ($diff -gt 0) { 'Green' } else { 'Red' })
|
||||
Write-Host ""
|
||||
|
||||
if ($diff -gt 0) {
|
||||
Write-Host "✓ §3.3 guest-clone is FASTER by $(Format-Elapsed ([TimeSpan]::FromSeconds($diff)))" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "⚠ Host-side is faster by $(Format-Elapsed ([TimeSpan]::FromSeconds(-$diff)))" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
Write-Host "One or both tests failed. Review logs above." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
if ($baselineResult.Success) {
|
||||
Write-Host " Baseline: ✓ PASS"
|
||||
}
|
||||
else {
|
||||
Write-Host " Baseline: ✗ FAIL"
|
||||
}
|
||||
if ($guestCloneResult.Success) {
|
||||
Write-Host " Guest: ✓ PASS"
|
||||
}
|
||||
else {
|
||||
Write-Host " Guest: ✗ FAIL"
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($baselineResult) {
|
||||
Write-Host ""
|
||||
Write-Host "Baseline (host-side): $(if ($baselineResult.Success) { '✓ PASS' } else { '✗ FAIL' })" -ForegroundColor $(if ($baselineResult.Success) { 'Green' } else { 'Red' })
|
||||
if ($baselineResult.Success) {
|
||||
Write-Host " Time: $(Format-Elapsed $baselineResult.Elapsed)"
|
||||
}
|
||||
}
|
||||
elseif ($guestCloneResult) {
|
||||
Write-Host ""
|
||||
Write-Host "Guest clone (-UseGit): $(if ($guestCloneResult.Success) { '✓ PASS' } else { '✗ FAIL' })" -ForegroundColor $(if ($guestCloneResult.Success) { 'Green' } else { 'Red' })
|
||||
if ($guestCloneResult.Success) {
|
||||
Write-Host " Time: $(Format-Elapsed $guestCloneResult.Elapsed)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Logs: F:\CI\Logs\$BaselineJobId or $GuestCloneJobId" -ForegroundColor Gray
|
||||
Write-Host "════════════════════════════════════════════════════════" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,167 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs a full end-to-end CI test build of nsis-plugin-ns7zip on the Linux template.
|
||||
|
||||
.DESCRIPTION
|
||||
Invokes Invoke-CIJob.ps1 with the ns7zip repo parameters targeting the Linux
|
||||
build template (LinuxBuild2404) and verifies the artifact was produced.
|
||||
The Linux build cross-compiles Windows DLLs via mingw-w64.
|
||||
|
||||
Exits 0 on success, 1 on failure.
|
||||
|
||||
.PARAMETER JobId
|
||||
Job identifier used for artifact and log directory names.
|
||||
Default: e2e-ns7zip-linux-<yyyyMMdd_HHmmss>
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch to build. Default: main
|
||||
|
||||
.PARAMETER Commit
|
||||
Optional specific commit SHA. Default: empty (HEAD of branch).
|
||||
|
||||
.PARAMETER Config
|
||||
Plugin config to build: x86-ansi | x86-unicode | x64-unicode. Default: x64-unicode
|
||||
|
||||
.PARAMETER ZipVersion
|
||||
7-Zip version (Linux-supported subset): 25.01 | 26.00 | 26.01 | zstd. Default: 26.01
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Path to the Linux template VMX. Default: env:GITEA_CI_LINUX_TEMPLATE_PATH or
|
||||
F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Snapshot name to clone from. Default: BaseClean-Linux
|
||||
|
||||
.PARAMETER SshKeyPath
|
||||
SSH private key for the ci_build user in the guest. Default: F:\CI\keys\ci_linux
|
||||
|
||||
.PARAMETER UseGitClone
|
||||
If set, skips host-side git clone and delegates cloning to the guest VM.
|
||||
Uses -GuestRepoUrl (HTTP) instead of the SSH alias URL.
|
||||
Mirrors the -UseGitClone mode of Invoke-CIJob.ps1 (same as §3.3 for Windows).
|
||||
|
||||
.PARAMETER GuestRepoUrl
|
||||
HTTP(S) URL used for in-VM clone when -UseGitClone is set.
|
||||
Must be reachable from inside the Linux guest VM.
|
||||
Default: https://gitea.emulab.it/Simone/nsis-plugin-ns7zip.git
|
||||
|
||||
.PARAMETER VerifyArtifact
|
||||
If set, expands artifacts.zip (or scans the dir) and checks that at least one DLL is present.
|
||||
Default: $true
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-Ns7zipBuild-Linux.ps1
|
||||
|
||||
.\Test-Ns7zipBuild-Linux.ps1 -Config x86-unicode -ZipVersion 26.00
|
||||
|
||||
# Guest-side clone (no host clone, guest fetches via HTTPS):
|
||||
.\Test-Ns7zipBuild-Linux.ps1 -UseGitClone
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $JobId = "e2e-ns7zip-linux-$(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||
[string] $Branch = 'main',
|
||||
[string] $Commit = '',
|
||||
[ValidateSet('x86-ansi', 'x86-unicode', 'x64-unicode')]
|
||||
[string] $Config = 'x64-unicode',
|
||||
[ValidateSet('25.01', '26.00', '26.01', 'zstd')]
|
||||
[string] $ZipVersion = '26.01',
|
||||
[string] $TemplatePath = $(
|
||||
if ($env:GITEA_CI_LINUX_TEMPLATE_PATH) { $env:GITEA_CI_LINUX_TEMPLATE_PATH }
|
||||
else { 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' }
|
||||
),
|
||||
[string] $SnapshotName = 'BaseClean-Linux',
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
[switch] $UseGitClone,
|
||||
[string] $GuestRepoUrl = 'https://gitea.emulab.it/Simone/nsis-plugin-ns7zip.git',
|
||||
[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"
|
||||
|
||||
Write-Host "============================================================"
|
||||
Write-Host " nsis-plugin-ns7zip Linux e2e test"
|
||||
Write-Host " JobId : $JobId"
|
||||
Write-Host " Branch : $Branch"
|
||||
if ($Commit) { Write-Host " Commit : $Commit" }
|
||||
Write-Host " Config : $Config"
|
||||
Write-Host " 7-Zip ver : $ZipVersion"
|
||||
Write-Host " Template : $TemplatePath"
|
||||
Write-Host " Snapshot : $SnapshotName"
|
||||
Write-Host " Artifacts : $artifactDir"
|
||||
Write-Host " Clone mode : $(if ($UseGitClone) { 'in-VM git clone (-UseGitClone)' } else { 'host clone + tar/scp' })"
|
||||
Write-Host "============================================================"
|
||||
Write-Host ""
|
||||
|
||||
# ns7zip Linux build copies built DLLs under plugins/<config>/nsis7z.dll
|
||||
$buildCmd = "python3 build_plugin.py --host linux --7zip-version $ZipVersion --configs $Config --verbose"
|
||||
|
||||
# Without -UseGitClone: host clones via SSH alias, transfers source via tar+scp.
|
||||
# With -UseGitClone: host clone is skipped; guest clones via HTTPS URL.
|
||||
$repoUrl = if ($UseGitClone) { $GuestRepoUrl } else { 'ssh://gitea-ci/Simone/nsis-plugin-ns7zip.git' }
|
||||
|
||||
$params = @{
|
||||
JobId = $JobId
|
||||
RepoUrl = $repoUrl
|
||||
Branch = $Branch
|
||||
TemplatePath = $TemplatePath
|
||||
SnapshotName = $SnapshotName
|
||||
Submodules = $true
|
||||
BuildCommand = $buildCmd
|
||||
GuestArtifactSource = 'plugins'
|
||||
GuestOS = 'Linux'
|
||||
SshKeyPath = $SshKeyPath
|
||||
SshUser = 'ci_build'
|
||||
}
|
||||
if ($UseGitClone) { $params['UseGitClone'] = $true }
|
||||
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 at least one .dll arrived from the guest
|
||||
if ($VerifyArtifact) {
|
||||
if (-not (Test-Path $artifactDir)) {
|
||||
Write-Host "[Test] FAIL - artifact directory not found: $artifactDir" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
$dlls = @(Get-ChildItem -Path $artifactDir -Recurse -Filter '*.dll' -ErrorAction SilentlyContinue)
|
||||
if ($dlls.Count -eq 0) {
|
||||
Write-Host "[Test] FAIL - no .dll found under $artifactDir" -ForegroundColor Red
|
||||
Get-ChildItem -Path $artifactDir -Recurse -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-Host " $($_.FullName)"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
foreach ($dll in $dlls) {
|
||||
$kb = [math]::Round($dll.Length / 1KB)
|
||||
Write-Host "[Test] Artifact OK: $($dll.FullName) ($kb KB)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[Test] PASS - completed in $($elapsed.ToString('mm\:ss'))" -ForegroundColor Green
|
||||
exit 0
|
||||
@@ -0,0 +1,173 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs a full end-to-end CI test build of nsis-plugin-nsinnounp on the Linux template.
|
||||
|
||||
.DESCRIPTION
|
||||
Invokes Invoke-CIJob.ps1 with the nsinnounp repo parameters targeting the Linux
|
||||
build template (LinuxBuild2404) and verifies the artifact was produced.
|
||||
Useful for smoke-testing the Linux CI pipeline after changes to scripts or
|
||||
the LinuxBuild2404 template VM.
|
||||
|
||||
Exits 0 on success, 1 on failure.
|
||||
|
||||
.PARAMETER JobId
|
||||
Job identifier used for artifact and log directory names.
|
||||
Default: e2e-linux-<yyyyMMdd_HHmmss>
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch to build. Default: main
|
||||
|
||||
.PARAMETER Commit
|
||||
Optional specific commit SHA. Default: empty (HEAD of branch).
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Path to the Linux template VMX.
|
||||
Default: env:GITEA_CI_LINUX_TEMPLATE_PATH or
|
||||
F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Snapshot name to clone from. Default: BaseClean-Linux
|
||||
|
||||
.PARAMETER SshKeyPath
|
||||
SSH private key for the ci_build user in the guest.
|
||||
Default: F:\CI\keys\ci_linux
|
||||
|
||||
.PARAMETER VerifyArtifact
|
||||
If set, expands artifacts.zip and checks that at least one file is present.
|
||||
Default: $true
|
||||
|
||||
.EXAMPLE
|
||||
# Quick smoke test (HEAD of main):
|
||||
.\Test-NsinnounpBuild-Linux.ps1
|
||||
|
||||
# Test a specific commit:
|
||||
.\Test-NsinnounpBuild-Linux.ps1 -Commit abc1234 -VerifyArtifact
|
||||
|
||||
# Custom job ID:
|
||||
.\Test-NsinnounpBuild-Linux.ps1 -JobId 'e2e-linux-001'
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $JobId = "e2e-linux-$(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||
[string] $Branch = 'main',
|
||||
[string] $Commit = '',
|
||||
[string] $TemplatePath = $(
|
||||
if ($env:GITEA_CI_LINUX_TEMPLATE_PATH) { $env:GITEA_CI_LINUX_TEMPLATE_PATH }
|
||||
else { 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' }
|
||||
),
|
||||
[string] $SnapshotName = 'BaseClean-Linux',
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
[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 Linux e2e test"
|
||||
Write-Host " JobId : $JobId"
|
||||
Write-Host " Branch : $Branch"
|
||||
if ($Commit) { Write-Host " Commit : $Commit" }
|
||||
Write-Host " Template : $TemplatePath"
|
||||
Write-Host " Snapshot : $SnapshotName"
|
||||
Write-Host " Artifacts: $artifactDir"
|
||||
Write-Host "============================================================"
|
||||
Write-Host ""
|
||||
|
||||
$params = @{
|
||||
JobId = $JobId
|
||||
RepoUrl = 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git'
|
||||
Branch = $Branch
|
||||
TemplatePath = $TemplatePath
|
||||
SnapshotName = $SnapshotName
|
||||
Submodules = $true
|
||||
BuildCommand = 'python3 build_plugin.py --final --dist-dir dist'
|
||||
GuestArtifactSource = 'dist'
|
||||
GuestOS = 'Linux'
|
||||
SshKeyPath = $SshKeyPath
|
||||
SshUser = 'ci_build'
|
||||
}
|
||||
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
|
||||
|
||||
# Accept either Windows cross-compiled artifacts (mingw) or native Linux artifacts
|
||||
$expectedPatterns = @(
|
||||
'*nsInnoUnp*'
|
||||
)
|
||||
$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'"
|
||||
$found | ForEach-Object { Write-Host "[Test] $($_.Name)" }
|
||||
}
|
||||
else {
|
||||
Write-Host "[Test] MISSING: no files matching '$pattern'" -ForegroundColor Red
|
||||
$allFound = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Fallback: accept any non-empty dist
|
||||
if (-not $allFound) {
|
||||
$allFiles = @(Get-ChildItem -Path $extractDir -Recurse -File -ErrorAction SilentlyContinue)
|
||||
if ($allFiles.Count -gt 0) {
|
||||
Write-Host "[Test] NOTE: pattern check failed but $($allFiles.Count) file(s) found in dist:" -ForegroundColor Yellow
|
||||
$allFiles | ForEach-Object { Write-Host "[Test] $($_.Name)" }
|
||||
Write-Host "[Test] Update -expectedPatterns in this script to match actual Linux output." -ForegroundColor Yellow
|
||||
$allFound = $true
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,146 @@
|
||||
#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\WinBuild2025\WinBuild2025.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
|
||||
|
||||
+110
-24
@@ -6,11 +6,15 @@
|
||||
.DESCRIPTION
|
||||
Performs a three-phase readiness check in a polling loop:
|
||||
1. vmrun getState returns "running"
|
||||
2. ICMP ping succeeds
|
||||
3. Test-WSMan (WinRM) responds without error
|
||||
2. Network reachability (ICMP ping for WinRM; TCP port 22 check for SSH)
|
||||
3. Transport-specific login: WinRM port 5986 (Windows) or SSH echo (Linux)
|
||||
|
||||
Throws if the VM does not become ready within the timeout period.
|
||||
|
||||
Transport modes:
|
||||
WinRM (default) — existing Windows VM path: ping + WinRM port 5986.
|
||||
SSH — Linux VM path: port 22 check + ssh echo test.
|
||||
|
||||
.PARAMETER VMPath
|
||||
Full path to the clone VM's .vmx file.
|
||||
|
||||
@@ -29,6 +33,19 @@
|
||||
Full path to vmrun.exe.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||
|
||||
.PARAMETER Transport
|
||||
Transport protocol for readiness checks.
|
||||
WinRM (default): checks ICMP ping + TCP 5986 (Windows VMs).
|
||||
SSH: checks TCP 22 + ssh echo (Linux VMs).
|
||||
|
||||
.PARAMETER SshKeyPath
|
||||
Path to the SSH private key for key-based auth when Transport=SSH.
|
||||
Default: F:\CI\keys\ci_linux
|
||||
|
||||
.PARAMETER SshUser
|
||||
SSH username when Transport=SSH.
|
||||
Default: ci_build
|
||||
|
||||
.EXAMPLE
|
||||
.\Wait-VMReady.ps1 `
|
||||
-VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx" `
|
||||
@@ -40,21 +57,32 @@ param(
|
||||
[string] $VMPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[ValidateRange(30, 600)]
|
||||
[ValidateRange(3, 600)]
|
||||
[int] $TimeoutSeconds = 300,
|
||||
|
||||
[ValidateRange(3, 30)]
|
||||
[ValidateRange(1, 30)]
|
||||
[int] $PollIntervalSeconds = 5,
|
||||
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
||||
|
||||
# Skip ICMP ping phase (phase 2). Useful when the guest firewall blocks ICMP
|
||||
# but WinRM (TCP 5985) is open. Phase 1 (vmrun state) and phase 3 (WinRM)
|
||||
# but WinRM (TCP 5986) is open. Phase 1 (vmrun state) and phase 3 (WinRM)
|
||||
# are still checked.
|
||||
[switch] $SkipPing
|
||||
[switch] $SkipPing,
|
||||
|
||||
# Transport protocol for phase 2/3 checks.
|
||||
# WinRM (default) = Windows VM; SSH = Linux VM.
|
||||
[ValidateSet('WinRM', 'SSH')]
|
||||
[string] $Transport = 'WinRM',
|
||||
|
||||
# SSH private key path (used when Transport=SSH).
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
# SSH username (used when Transport=SSH).
|
||||
[string] $SshUser = 'ci_build'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
@@ -75,14 +103,21 @@ while ((Get-Date) -lt $deadline) {
|
||||
$attempt++
|
||||
|
||||
# ── Phase 1: VM must be running ─────────────────────────────────────
|
||||
# vmrun has no getState; getGuestIPAddress fails with "not powered on"
|
||||
# when the VM is off, and succeeds (exit 0) when running.
|
||||
try {
|
||||
& $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null
|
||||
$isRunning = ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
catch {
|
||||
# vmrun has no getState. We previously used `getGuestIPAddress`, but that
|
||||
# requires VMware Tools to be up and responding inside the guest. In
|
||||
# headless boots Tools can take 30-60s to come online, during which the
|
||||
# call exits non-zero even though the VM is fully powered on. That caused
|
||||
# Phase 1 to appear "stuck" until a GUI was opened manually.
|
||||
# `vmrun list` reports actual power state without needing Tools.
|
||||
$vmxNorm = (Resolve-Path -LiteralPath $VMPath -ErrorAction SilentlyContinue)
|
||||
if ($null -ne $vmxNorm) { $vmxNorm = $vmxNorm.Path } else { $vmxNorm = $VMPath }
|
||||
$listOut = & $VmrunPath list 2>&1
|
||||
$isRunning = $false
|
||||
foreach ($line in $listOut) {
|
||||
if ([string]::Equals($line.Trim(), $vmxNorm, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$isRunning = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $isRunning) {
|
||||
@@ -97,8 +132,22 @@ while ((Get-Date) -lt $deadline) {
|
||||
continue
|
||||
}
|
||||
|
||||
# ── Phase 2: Network ping (optional) ───────────────────────────────
|
||||
if (-not $SkipPing) {
|
||||
# ── Phase 2: Network check ──────────────────────────────────────────
|
||||
if ($Transport -eq 'SSH') {
|
||||
$port22Open = Test-NetConnection -ComputerName $IPAddress -Port 22 `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue `
|
||||
-ErrorAction SilentlyContinue
|
||||
if (-not $port22Open) {
|
||||
$phase = 'ssh-port'
|
||||
if ($lastPhase -ne $phase) {
|
||||
Write-Host "[Wait-VMReady] Phase 2/3: VM running, waiting for SSH port 22 at $IPAddress..."
|
||||
$lastPhase = $phase
|
||||
}
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
continue
|
||||
}
|
||||
}
|
||||
elseif (-not $SkipPing) {
|
||||
$pingOk = Test-Connection -ComputerName $IPAddress -Count 1 -Quiet -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $pingOk) {
|
||||
@@ -112,23 +161,60 @@ while ((Get-Date) -lt $deadline) {
|
||||
}
|
||||
}
|
||||
|
||||
# ── Phase 3: WinRM responsive ────────────────────────────────────────
|
||||
try {
|
||||
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
|
||||
# Success — VM is ready
|
||||
$elapsed = ($deadline - (Get-Date)).Negate().TotalSeconds + $TimeoutSeconds
|
||||
# ── Phase 3: Transport-specific login check ────────────────────────
|
||||
if ($Transport -eq 'SSH') {
|
||||
$sshArgs = @(
|
||||
'-i', $SshKeyPath,
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=NUL',
|
||||
'-o', 'ConnectTimeout=5',
|
||||
'-o', 'BatchMode=yes',
|
||||
"$SshUser@$IPAddress",
|
||||
'echo ready'
|
||||
)
|
||||
# ssh writes diagnostic warnings (e.g. "Permanently added ... to known hosts")
|
||||
# to stderr. Under $ErrorActionPreference='Stop' (set at script top),
|
||||
# those stderr lines captured via 2>&1 are promoted to terminating errors
|
||||
# and abort the script. Wrap the call to demote stderr to non-terminating.
|
||||
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
$sshOut = & ssh @sshArgs 2>&1
|
||||
$sshExit = $LASTEXITCODE
|
||||
$ErrorActionPreference = $savedEap
|
||||
if ($sshExit -eq 0 -and ($sshOut -join '') -like '*ready*') {
|
||||
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
|
||||
return
|
||||
}
|
||||
catch {
|
||||
$phase = 'winrm'
|
||||
else {
|
||||
$phase = 'ssh-echo'
|
||||
if ($lastPhase -ne $phase) {
|
||||
Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5985..."
|
||||
Write-Host "[Wait-VMReady] Phase 3/3: SSH port open, waiting for login ($SshUser@$IPAddress)..."
|
||||
$lastPhase = $phase
|
||||
}
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
# ── WinRM port 5986 accepting TCP connections ─────────────────────
|
||||
# Test-WSMan has no -SessionOption in PS 5.1 and fails cert validation for
|
||||
# self-signed certs. TCP open on 5986 = HTTPS listener up = VM ready.
|
||||
$winrmUp = Test-NetConnection -ComputerName $IPAddress -Port 5986 `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue `
|
||||
-ErrorAction SilentlyContinue
|
||||
if ($winrmUp) {
|
||||
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
|
||||
return
|
||||
}
|
||||
else {
|
||||
$phase = 'winrm'
|
||||
if ($lastPhase -ne $phase) {
|
||||
Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5986 (HTTPS)..."
|
||||
$lastPhase = $phase
|
||||
}
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "[Wait-VMReady] Timed out after ${TimeoutSeconds}s waiting for VM at $IPAddress to become ready. Last phase: $phase"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks CI host drive free space and alerts via Windows Event Log (and optionally a webhook).
|
||||
|
||||
.DESCRIPTION
|
||||
Intended to run as a scheduled task every 15 minutes (registered by
|
||||
Register-CIScheduledTasks.ps1). If the monitored drive is below MinFreeGB,
|
||||
writes a Warning entry to the Windows Application Event Log under source
|
||||
'CI-DiskAlert' (Event ID 1001) and exits with code 1 so Task Scheduler
|
||||
marks the task as failed (visible in Task Scheduler history).
|
||||
|
||||
If WebhookUrl is provided, also POSTs a JSON payload to that URL
|
||||
(compatible with Discord and Gitea webhooks — content field only).
|
||||
|
||||
A full drive silently causes vmrun clone to fail with cryptic errors
|
||||
(not enough disk space for linked clone delta files). Alert early.
|
||||
|
||||
.PARAMETER MinFreeGB
|
||||
Alert threshold in GB. Default: 50
|
||||
|
||||
.PARAMETER DriveLetter
|
||||
Single letter (no colon) of the drive to monitor. Default: F
|
||||
|
||||
.PARAMETER WebhookUrl
|
||||
Optional Discord/Gitea webhook URL. If empty, only Event Log is written.
|
||||
|
||||
.EXAMPLE
|
||||
# Manual check
|
||||
.\Watch-DiskSpace.ps1 -MinFreeGB 50
|
||||
|
||||
# With Discord webhook
|
||||
.\Watch-DiskSpace.ps1 -WebhookUrl "https://discord.com/api/webhooks/..."
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateRange(1, 2000)]
|
||||
[int] $MinFreeGB = 50,
|
||||
|
||||
[ValidatePattern('^[A-Za-z]$')]
|
||||
[string] $DriveLetter = 'F',
|
||||
|
||||
[string] $WebhookUrl = ''
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$drive = Get-PSDrive -Name $DriveLetter -ErrorAction SilentlyContinue
|
||||
if (-not $drive) {
|
||||
Write-Warning "[DiskSpace] Drive ${DriveLetter}: not found."
|
||||
exit 2
|
||||
}
|
||||
|
||||
$freeGB = [math]::Round($drive.Free / 1GB, 1)
|
||||
$totalGB = [math]::Round(($drive.Free + $drive.Used) / 1GB, 1)
|
||||
$pctFree = [math]::Round($freeGB / $totalGB * 100, 0)
|
||||
|
||||
if ($freeGB -ge $MinFreeGB) {
|
||||
Write-Host "[DiskSpace] Drive ${DriveLetter}: ${freeGB} GB free / ${totalGB} GB (${pctFree}%) — OK (threshold: ${MinFreeGB} GB)"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Alert ─────────────────────────────────────────────────────────────────────
|
||||
$msg = "CI host drive ${DriveLetter}: free space ${freeGB} GB (${pctFree}%) is below " +
|
||||
"the ${MinFreeGB} GB threshold. vmrun linked clones may fail silently. " +
|
||||
"Run Invoke-RetentionPolicy.ps1 or free disk space manually."
|
||||
|
||||
Write-Warning "[DiskSpace] $msg"
|
||||
|
||||
# Windows Event Log
|
||||
$logSource = 'CI-DiskAlert'
|
||||
try {
|
||||
if (-not [System.Diagnostics.EventLog]::SourceExists($logSource)) {
|
||||
New-EventLog -LogName Application -Source $logSource -ErrorAction Stop
|
||||
}
|
||||
Write-EventLog -LogName Application -Source $logSource `
|
||||
-EventId 1001 -EntryType Warning -Message $msg
|
||||
Write-Host "[DiskSpace] Event Log entry written (Application/$logSource, EventId 1001)."
|
||||
} catch {
|
||||
Write-Warning "[DiskSpace] Could not write Event Log: $_"
|
||||
}
|
||||
|
||||
# Optional webhook (Discord / Gitea)
|
||||
if ($WebhookUrl) {
|
||||
$body = @{ content = ":warning: **CI Disk Alert** — $msg" } | ConvertTo-Json -Compress
|
||||
try {
|
||||
$null = Invoke-RestMethod -Uri $WebhookUrl -Method Post `
|
||||
-Body $body -ContentType 'application/json' -TimeoutSec 10
|
||||
Write-Host "[DiskSpace] Webhook notified: $WebhookUrl"
|
||||
} catch {
|
||||
Write-Warning "[DiskSpace] Webhook POST failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
exit 1 # non-zero → Task Scheduler marks run as failed (visible in history/Event Viewer)
|
||||
@@ -0,0 +1,161 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Monitors the act_runner service and auto-restarts it if stopped.
|
||||
|
||||
.DESCRIPTION
|
||||
Intended to run as a scheduled task every 15 minutes (registered by
|
||||
Register-CIScheduledTasks.ps1 as CI-RunnerHealth).
|
||||
|
||||
Behaviour:
|
||||
- If act_runner is Running: exits 0, no action.
|
||||
- If not Running: writes a Warning to the Windows Application Event Log
|
||||
(source CI-RunnerHealth, EventId 1002), then attempts Restart-Service.
|
||||
- Restart attempts are rate-limited to MaxRestarts per hour using a JSON
|
||||
state file in StateDir. Once the limit is hit, further runs write an
|
||||
alert (EventId 1004) and exit 1 without restarting — Task Scheduler
|
||||
history shows the failure so the operator is alerted.
|
||||
- On successful restart: writes EventId 1003 (Information).
|
||||
- Optional WebhookUrl: POSTs a JSON payload compatible with Discord and
|
||||
Gitea webhooks on every restart or limit-exceeded event.
|
||||
|
||||
.PARAMETER MaxRestarts
|
||||
Maximum number of auto-restart attempts allowed per rolling 60-minute
|
||||
window before giving up and requiring manual intervention. Default: 3
|
||||
|
||||
.PARAMETER ServiceName
|
||||
Windows service name to monitor. Default: act_runner
|
||||
|
||||
.PARAMETER StateDir
|
||||
Directory used for the cooldown state file (runner-restart-log.json).
|
||||
Default: F:\CI\State
|
||||
|
||||
.PARAMETER WebhookUrl
|
||||
Optional Discord/Gitea webhook URL. If empty, only Event Log is written.
|
||||
|
||||
.EXAMPLE
|
||||
# Manual health check
|
||||
.\Watch-RunnerHealth.ps1
|
||||
|
||||
# With Discord webhook
|
||||
.\Watch-RunnerHealth.ps1 -WebhookUrl "https://discord.com/api/webhooks/..."
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateRange(1, 10)]
|
||||
[int] $MaxRestarts = 3,
|
||||
|
||||
[string] $ServiceName = 'act_runner',
|
||||
|
||||
[string] $StateDir = 'F:\CI\State',
|
||||
|
||||
[string] $WebhookUrl = ''
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$logSource = 'CI-RunnerHealth'
|
||||
|
||||
# ── Helper: write to Event Log ────────────────────────────────────────────────
|
||||
function Write-CIEvent {
|
||||
param([int] $EventId, [string] $EntryType, [string] $Message)
|
||||
try {
|
||||
if (-not [System.Diagnostics.EventLog]::SourceExists($logSource)) {
|
||||
New-EventLog -LogName Application -Source $logSource -ErrorAction Stop
|
||||
}
|
||||
Write-EventLog -LogName Application -Source $logSource `
|
||||
-EventId $EventId -EntryType $EntryType -Message $Message
|
||||
} catch {
|
||||
Write-Warning "[RunnerHealth] Could not write Event Log: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Helper: POST webhook ──────────────────────────────────────────────────────
|
||||
function Send-Webhook {
|
||||
param([string] $Content)
|
||||
if (-not $WebhookUrl) { return }
|
||||
$body = @{ content = $Content } | ConvertTo-Json -Compress
|
||||
try {
|
||||
$null = Invoke-RestMethod -Uri $WebhookUrl -Method Post `
|
||||
-Body $body -ContentType 'application/json' -TimeoutSec 10
|
||||
Write-Host "[RunnerHealth] Webhook notified."
|
||||
} catch {
|
||||
Write-Warning "[RunnerHealth] Webhook POST failed: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 1: Check service ─────────────────────────────────────────────────────
|
||||
$svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||
if (-not $svc) {
|
||||
Write-Warning "[RunnerHealth] Service '$ServiceName' not found — is act_runner installed?"
|
||||
exit 2
|
||||
}
|
||||
|
||||
if ($svc.Status -eq 'Running') {
|
||||
Write-Host "[RunnerHealth] $ServiceName is Running — OK"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Step 2: Service not running — load cooldown state ─────────────────────────
|
||||
if (-not (Test-Path $StateDir)) {
|
||||
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$cooldownFile = Join-Path $StateDir 'runner-restart-log.json'
|
||||
$restartLog = @()
|
||||
|
||||
if (Test-Path $cooldownFile) {
|
||||
try {
|
||||
$raw = Get-Content $cooldownFile -Raw -ErrorAction SilentlyContinue
|
||||
if ($raw) { $restartLog = @($raw | ConvertFrom-Json) }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
# Prune timestamps older than 1 hour
|
||||
$cutoff = (Get-Date).AddHours(-1)
|
||||
$restartLog = @($restartLog | Where-Object { [datetime]$_ -gt $cutoff })
|
||||
$recentCount = $restartLog.Count
|
||||
|
||||
$stateMsg = "$ServiceName service is '$($svc.Status)'. Auto-restarts in last hour: $recentCount / $MaxRestarts."
|
||||
Write-Warning "[RunnerHealth] $stateMsg"
|
||||
Write-CIEvent -EventId 1002 -EntryType Warning -Message $stateMsg
|
||||
|
||||
# ── Step 3: Rate-limit check ──────────────────────────────────────────────────
|
||||
if ($recentCount -ge $MaxRestarts) {
|
||||
$limitMsg = "$ServiceName auto-restart limit ($MaxRestarts/h) reached — manual intervention required. " +
|
||||
"Check Event Viewer → Application (source: $logSource) and F:\CI\act_runner\logs\."
|
||||
Write-Warning "[RunnerHealth] $limitMsg"
|
||||
Write-CIEvent -EventId 1004 -EntryType Error -Message $limitMsg
|
||||
Send-Webhook -Content ":sos: **CI Runner Alert** — $limitMsg"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Step 4: Attempt restart ───────────────────────────────────────────────────
|
||||
Write-Host "[RunnerHealth] Restarting $ServiceName (attempt $($recentCount + 1) of $MaxRestarts this hour)..."
|
||||
try {
|
||||
Restart-Service -Name $ServiceName -Force -ErrorAction Stop
|
||||
Start-Sleep -Seconds 5
|
||||
$newStatus = (Get-Service -Name $ServiceName).Status
|
||||
$restartMsg = "$ServiceName restarted — new status: $newStatus."
|
||||
Write-Host "[RunnerHealth] $restartMsg"
|
||||
|
||||
# Persist this restart in the cooldown log
|
||||
$restartLog += (Get-Date -Format 'o')
|
||||
$restartLog | ConvertTo-Json | Set-Content $cooldownFile -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
|
||||
$evtType = if ($newStatus -eq 'Running') { 'Information' } else { 'Warning' }
|
||||
Write-CIEvent -EventId 1003 -EntryType $evtType -Message $restartMsg
|
||||
|
||||
$icon = if ($newStatus -eq 'Running') { ':warning:' } else { ':sos:' }
|
||||
Send-Webhook -Content "$icon **CI Runner Alert** — $restartMsg"
|
||||
|
||||
exit $(if ($newStatus -eq 'Running') { 0 } else { 1 })
|
||||
}
|
||||
catch {
|
||||
$errMsg = "$ServiceName restart failed: $_"
|
||||
Write-Warning "[RunnerHealth] $errMsg"
|
||||
Write-CIEvent -EventId 1004 -EntryType Error -Message $errMsg
|
||||
Send-Webhook -Content ":sos: **CI Runner Alert** — $errMsg"
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Shared helper functions for the Local CI/CD System scripts.
|
||||
|
||||
.DESCRIPTION
|
||||
Import this module at the top of any CI script that needs WinRM session
|
||||
options or vmrun wrappers:
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
||||
|
||||
Exported functions:
|
||||
New-CISessionOption — PSSessionOption for WinRM over self-signed TLS
|
||||
Resolve-VmrunPath — Validates vmrun.exe path, throws if missing
|
||||
Invoke-Vmrun — Uniform vmrun -T ws wrapper; returns ExitCode + Output
|
||||
#>
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function New-CISessionOption {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Returns a PSSessionOption for WinRM HTTPS with self-signed certificate.
|
||||
.DESCRIPTION
|
||||
All CI build VMs use a self-signed TLS cert on WinRM port 5986.
|
||||
This option set skips CA, CN, and revocation checks — appropriate for
|
||||
an isolated lab network where PKI is not present.
|
||||
.OUTPUTS
|
||||
[System.Management.Automation.Remoting.PSSessionOption]
|
||||
#>
|
||||
[OutputType([System.Management.Automation.Remoting.PSSessionOption])]
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
}
|
||||
|
||||
function Resolve-VmrunPath {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates that vmrun.exe exists at the specified path and returns it.
|
||||
.DESCRIPTION
|
||||
Throws a descriptive error if the file is not found, rather than letting
|
||||
a later & call fail with a less useful message.
|
||||
.PARAMETER VmrunPath
|
||||
Full path to vmrun.exe.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||
.OUTPUTS
|
||||
[string] The validated VmrunPath.
|
||||
#>
|
||||
[OutputType([string])]
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
||||
)
|
||||
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
|
||||
throw "vmrun.exe not found at: $VmrunPath`nInstall VMware Workstation Pro and update -VmrunPath."
|
||||
}
|
||||
return $VmrunPath
|
||||
}
|
||||
|
||||
function Invoke-Vmrun {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs a vmrun -T ws <Operation> command and returns output and exit code.
|
||||
.DESCRIPTION
|
||||
Uniform wrapper so all vmrun calls share the same -T ws flag and output
|
||||
capture pattern. Use -ThrowOnError for operations that must succeed
|
||||
(clone, start); omit it for best-effort operations (stop, getState).
|
||||
.PARAMETER VmrunPath
|
||||
Full path to vmrun.exe.
|
||||
.PARAMETER Operation
|
||||
vmrun sub-command: clone, start, stop, deleteVM, list, getState,
|
||||
listSnapshots, getGuestIPAddress, etc.
|
||||
.PARAMETER Arguments
|
||||
Additional positional arguments after the operation name.
|
||||
.PARAMETER ThrowOnError
|
||||
When set, throws if vmrun exits non-zero.
|
||||
.OUTPUTS
|
||||
[pscustomobject] with:
|
||||
ExitCode [int] — vmrun exit code
|
||||
Output [string[]] — combined stdout/stderr lines
|
||||
#>
|
||||
[OutputType([pscustomobject])]
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $VmrunPath,
|
||||
[Parameter(Mandatory)] [string] $Operation,
|
||||
[string[]] $Arguments = @(),
|
||||
[switch] $ThrowOnError
|
||||
)
|
||||
$cmdArgs = @('-T', 'ws', $Operation) + $Arguments
|
||||
$output = & $VmrunPath @cmdArgs 2>&1
|
||||
$exit = $LASTEXITCODE
|
||||
|
||||
if ($ThrowOnError -and $exit -ne 0) {
|
||||
throw "vmrun $Operation failed (exit $exit): $($output -join '; ')"
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $exit
|
||||
Output = $output
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function New-CISessionOption, Resolve-VmrunPath, Invoke-Vmrun
|
||||
@@ -0,0 +1,181 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
SSH/SCP transport helpers for Linux build VMs.
|
||||
|
||||
.DESCRIPTION
|
||||
SSH-only transport layer for the Linux branch of the CI/CD system.
|
||||
The existing WinRM transport (used by Windows VMs) is NOT modified.
|
||||
|
||||
Exported functions:
|
||||
Invoke-SshCommand — run a command in a Linux guest via ssh.exe
|
||||
Copy-SshItem — copy files to/from a Linux guest via scp.exe
|
||||
Test-SshReady — poll until SSH is ready (port 22 open + login succeeds)
|
||||
|
||||
Requirements:
|
||||
- ssh.exe and scp.exe in PATH (default on Windows 11 / Server 2022)
|
||||
- SSH private key at the path provided
|
||||
- Guest user must be set up for key-based auth (no passphrase)
|
||||
#>
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Invoke-SshCommand {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Run a command in a Linux guest via ssh.exe
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $IP,
|
||||
|
||||
[string] $User = 'ci_build',
|
||||
|
||||
[string] $KeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Command,
|
||||
|
||||
[int] $ConnectTimeout = 10,
|
||||
|
||||
# Capture + return stdout/stderr instead of printing to console.
|
||||
[switch] $PassThru,
|
||||
|
||||
# Don't throw on non-zero exit — just return the output.
|
||||
[switch] $AllowFail
|
||||
)
|
||||
|
||||
$sshArgs = @(
|
||||
'-i', $KeyPath,
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=NUL',
|
||||
'-o', "ConnectTimeout=$ConnectTimeout",
|
||||
'-o', 'BatchMode=yes',
|
||||
"$User@$IP",
|
||||
$Command
|
||||
)
|
||||
|
||||
if ($PassThru) {
|
||||
$output = & ssh @sshArgs 2>&1
|
||||
$exit = $LASTEXITCODE
|
||||
}
|
||||
else {
|
||||
& ssh @sshArgs
|
||||
$exit = $LASTEXITCODE
|
||||
$output = @()
|
||||
}
|
||||
|
||||
if ($exit -ne 0 -and -not $AllowFail) {
|
||||
throw "[SSH] Command failed (exit $exit): $Command"
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExitCode = [int]$exit
|
||||
Output = [string[]]$output
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-SshItem {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Copy files to/from Linux guest via scp.exe
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Source,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Destination,
|
||||
|
||||
# Guest IP — used to build user@host prefix when Direction is set.
|
||||
[string] $IP = '',
|
||||
|
||||
[string] $User = 'ci_build',
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $KeyPath,
|
||||
|
||||
[ValidateSet('ToGuest', 'FromGuest', 'Direct')]
|
||||
[string] $Direction = 'Direct',
|
||||
|
||||
# Pass -r to scp (recursive copy).
|
||||
[switch] $Recurse,
|
||||
|
||||
[int] $ConnectTimeout = 10
|
||||
)
|
||||
|
||||
if ($Direction -eq 'ToGuest') {
|
||||
$Destination = "$User@${IP}:$Destination"
|
||||
}
|
||||
elseif ($Direction -eq 'FromGuest') {
|
||||
$Source = "$User@${IP}:$Source"
|
||||
}
|
||||
# Direction = 'Direct': use Source + Destination as-is
|
||||
|
||||
$scpArgs = @(
|
||||
'-i', $KeyPath,
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=NUL',
|
||||
'-o', "ConnectTimeout=$ConnectTimeout"
|
||||
)
|
||||
if ($Recurse) { $scpArgs += '-r' }
|
||||
$scpArgs += @($Source, $Destination)
|
||||
|
||||
& scp @scpArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[SCP] Copy failed (exit $LASTEXITCODE): $Source -> $Destination"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-SshReady {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Poll until SSH port 22 is open and a login command succeeds.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $IP,
|
||||
|
||||
[string] $User = 'ci_build',
|
||||
|
||||
[string] $KeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
[int] $TimeoutSec = 300,
|
||||
|
||||
[int] $PollSec = 10
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
|
||||
# Phase 1: wait for port 22 to be open
|
||||
$portOpen = $false
|
||||
while (-not $portOpen -and (Get-Date) -lt $deadline) {
|
||||
$portOpen = (Test-NetConnection -ComputerName $IP -Port 22 `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue `
|
||||
-ErrorAction SilentlyContinue)
|
||||
if (-not $portOpen) {
|
||||
Start-Sleep -Seconds $PollSec
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $portOpen) {
|
||||
throw "[Test-SshReady] Timed out after ${TimeoutSec}s waiting for SSH at $IP"
|
||||
}
|
||||
|
||||
# Phase 2: try ssh echo — success when output contains 'ready'
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$result = Invoke-SshCommand -IP $IP -User $User -KeyPath $KeyPath `
|
||||
-Command 'echo ready' -AllowFail -PassThru
|
||||
if ($result.ExitCode -eq 0 -and ($result.Output -join '') -like '*ready*') {
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Seconds $PollSec
|
||||
}
|
||||
|
||||
throw "[Test-SshReady] Timed out after ${TimeoutSec}s waiting for SSH at $IP"
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-SshCommand, Copy-SshItem, Test-SshReady
|
||||
@@ -0,0 +1,677 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Deploys an Ubuntu 24.04 LTS build VM on VMware Workstation using a cloud
|
||||
VMDK and a cloud-init seed ISO.
|
||||
|
||||
.DESCRIPTION
|
||||
Host-side orchestrator. Produces a ready-to-use Linux template VM:
|
||||
Step 1 — Validate prerequisites + download cloud VMDK if not cached
|
||||
Step 2 — Generate cloud-init user-data + meta-data files
|
||||
Step 3 — Build cloud-init seed ISO (nocloud datasource, label: cidata)
|
||||
Step 4 — Copy and resize the cloud VMDK
|
||||
Step 5 — Generate the .vmx (EFI, SCSI disk, NAT vmxnet3, seed ISO)
|
||||
Step 6 — Power on the VM (vmrun start gui/nogui -- async)
|
||||
Step 7 — Detect guest IP via vmrun getGuestIPAddress (polls up to 5 min)
|
||||
Step 8 — Poll SSH port 22 until ready
|
||||
Step 9 — Verify cloud-init completed (boot-finished flag)
|
||||
Step 10 — Remove seed ISO from VMX (prevent re-run on clone)
|
||||
Final — Print summary
|
||||
|
||||
cloud-init nocloud datasource:
|
||||
Two files (user-data + meta-data) are written to a staging directory and
|
||||
burned into a small ISO labelled "cidata". On first boot, cloud-init reads
|
||||
both files and creates the $CloudUser account with the SSH public key.
|
||||
No packages are installed and no runcmd runs: all provisioning (apt,
|
||||
toolchain, SSH hardening, swap) is handled by Prepare-LinuxBuild2404.ps1
|
||||
via Setup-LinuxBuild2404.sh. Step 9 waits for
|
||||
/var/lib/cloud/instance/boot-finished before proceeding.
|
||||
|
||||
NIC:
|
||||
vmxnet3 + NAT. Ubuntu 24.04 cloud images ship with open-vm-tools which
|
||||
provides the guest IP reporting used in Step 7.
|
||||
|
||||
Disk:
|
||||
Official Ubuntu 24.04 Server cloud VMDK from cloud-images.ubuntu.com.
|
||||
Downloaded and cached to $VmdkCachePath on first run. Expanded to
|
||||
$DiskSizeGB GB in Step 4 using vmware-vdiskmanager.
|
||||
|
||||
.PARAMETER VMXPath
|
||||
Full path of the .vmx file to create. The folder is created if missing.
|
||||
|
||||
.PARAMETER VMName
|
||||
Display name in the Workstation library.
|
||||
|
||||
.PARAMETER CloudUser
|
||||
Username for the cloud-init created account. Default: ci_build.
|
||||
|
||||
.PARAMETER Hostname
|
||||
Guest hostname embedded in cloud-init meta-data and user-data.
|
||||
|
||||
.PARAMETER Timezone
|
||||
IANA timezone string for the guest. Default: Europe/Rome.
|
||||
|
||||
.PARAMETER DiskSizeGB
|
||||
Target disk size in GB after resize. Default: 40.
|
||||
|
||||
.PARAMETER MemoryMB
|
||||
Guest RAM in MiB. Default: 4096.
|
||||
|
||||
.PARAMETER VCPUCount
|
||||
Total vCPU count. Default: 4.
|
||||
|
||||
.PARAMETER CoresPerSocket
|
||||
Cores per socket. Default: 2.
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Snapshot name recorded in the summary. The snapshot itself is taken by
|
||||
Prepare-LinuxBuild2404.ps1.
|
||||
|
||||
.PARAMETER VMwareWorkstationDir
|
||||
VMware Workstation install directory.
|
||||
|
||||
.PARAMETER SshKeyPath
|
||||
Path to the SSH private key. The public key is inferred as SshKeyPath.pub.
|
||||
|
||||
.PARAMETER VmdkUrl
|
||||
URL to download the Ubuntu 24.04 cloud VMDK.
|
||||
|
||||
.PARAMETER VmdkCachePath
|
||||
Local cache path for the downloaded VMDK. Reused across runs.
|
||||
|
||||
.PARAMETER SeedIsoPath
|
||||
Path for the cloud-init seed ISO. Default: <VMDir>\cloud-init-seed.iso.
|
||||
|
||||
.PARAMETER SshTimeoutSeconds
|
||||
Total time in seconds to wait for SSH port 22 to become reachable. Default: 300.
|
||||
|
||||
.PARAMETER SshPollIntervalSeconds
|
||||
Seconds between SSH port poll attempts. Default: 10.
|
||||
|
||||
.PARAMETER ShowGui
|
||||
When set, vmrun powers on the VM with the Workstation GUI visible.
|
||||
Default: headless (nogui) for unattended pipelines.
|
||||
|
||||
.PARAMETER Force
|
||||
Allow overwriting an existing VM directory. Without this switch, the script
|
||||
aborts if the VMX destination folder already exists.
|
||||
|
||||
.PARAMETER DiagPassword
|
||||
If specified, adds a chpasswd block to cloud-init user-data that sets this
|
||||
plaintext password on the 'ubuntu' account. Allows console login for
|
||||
diagnostics. Do NOT use in production; remove or omit once the template
|
||||
is verified.
|
||||
|
||||
.PARAMETER StartFromStep
|
||||
Skip straight to this step number. All derived paths are still computed;
|
||||
artifacts from skipped steps must already exist on disk.
|
||||
Steps: 1=prereqs+vmdk 2=cloud-init files 3=seed ISO 4=vmdk copy+resize
|
||||
5=VMX 6=power on 7=guest IP 8=SSH port 9=cloud-init verify
|
||||
10=detach seed ISO
|
||||
|
||||
.NOTES
|
||||
Does not require elevation. Assumes the host has vmrun.exe and
|
||||
vmware-vdiskmanager.exe in the VMware Workstation directory, and ssh.exe
|
||||
in PATH (OpenSSH for Windows or Git for Windows).
|
||||
|
||||
.EXAMPLE
|
||||
.\Deploy-LinuxBuild2404.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Deploy-LinuxBuild2404.ps1 -StartFromStep 6
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string] $VMXPath = 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx',
|
||||
[string] $VMName = 'LinuxBuild2404',
|
||||
[string] $CloudUser = 'ci_build',
|
||||
[string] $Hostname = 'ci-linux-template',
|
||||
[string] $Timezone = 'Europe/Rome',
|
||||
[int] $DiskSizeGB = 40,
|
||||
[int] $MemoryMB = 4096,
|
||||
[int] $VCPUCount = 4,
|
||||
[int] $CoresPerSocket = 2,
|
||||
[string] $SnapshotName = 'PostInstall-Linux',
|
||||
[string] $VMwareWorkstationDir = 'C:\Program Files (x86)\VMware\VMware Workstation',
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
[string] $VmdkUrl = 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.vmdk',
|
||||
[string] $VmdkCachePath = 'F:\CI\ISO\noble-server-cloudimg-amd64.vmdk',
|
||||
[string] $SeedIsoPath = '',
|
||||
[int] $SshTimeoutSeconds = 300,
|
||||
[int] $SshPollIntervalSeconds = 10,
|
||||
[switch] $ShowGui,
|
||||
[switch] $Force,
|
||||
[string] $DiagPassword = '',
|
||||
[ValidateRange(1,10)]
|
||||
[int] $StartFromStep = 1
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
function Write-Step {
|
||||
param([string] $Message, [int] $Step = 0)
|
||||
$skipped = $Step -gt 0 -and $script:StartFromStep -gt $Step
|
||||
if ($skipped) {
|
||||
Write-Host "`n=== $Message === [SKIP]" -ForegroundColor DarkGray
|
||||
} else {
|
||||
Write-Host "`n=== $Message ===" -ForegroundColor Cyan
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Step {
|
||||
param(
|
||||
[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;
|
||||
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) {
|
||||
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 {
|
||||
# FileSystemsToCreate = 3 = ISO9660 + Joliet (no UDF).
|
||||
# With UDF present (=7), Linux blkid reads the UDF superblock first;
|
||||
# IMAPI2FS leaves the UDF volume label empty, so blkid reports no label
|
||||
# and cloud-init's nocloud detector never finds the 'cidata' device.
|
||||
# ISO9660+Joliet only: blkid reads the ISO9660 PVD label ('CIDATA' after
|
||||
# ISO9660 uppercasing) which cloud-init accepts.
|
||||
# ChooseImageDefaultsForMediaType is intentionally omitted -- it overrides
|
||||
# FileSystemsToCreate and re-enables UDF on some IMAPI versions.
|
||||
$fsi.FileSystemsToCreate = 3 # ISO9660 | Joliet
|
||||
$fsi.VolumeName = $VolumeLabel
|
||||
$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 Invoke-Vmrun {
|
||||
param([Parameter(Mandatory)] [string[]] $Arguments,
|
||||
[switch] $IgnoreErrors,
|
||||
[switch] $Async)
|
||||
$vmrun = Join-Path $VMwareWorkstationDir 'vmrun.exe'
|
||||
if ($Async) {
|
||||
# vmrun start can block indefinitely on some Workstation versions; fire async.
|
||||
Start-Process -FilePath $vmrun -ArgumentList $Arguments -NoNewWindow
|
||||
return
|
||||
}
|
||||
$stdout = & $vmrun @Arguments 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ne 0 -and -not $IgnoreErrors) {
|
||||
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
|
||||
}
|
||||
|
||||
function Test-VmInList {
|
||||
param([Parameter(Mandatory)] [string] $VmxPath)
|
||||
$list = Invoke-Vmrun -Arguments @('-T','ws','list') -IgnoreErrors
|
||||
$forward = $VmxPath -replace '\\', '/'
|
||||
$back = $VmxPath -replace '/', '\'
|
||||
return ($list -match [regex]::Escape($forward)) -or ($list -match [regex]::Escape($back))
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Derived paths -- always computed regardless of -StartFromStep
|
||||
# -----------------------------------------------------------------------------
|
||||
$vmrunExe = Join-Path $VMwareWorkstationDir 'vmrun.exe'
|
||||
$vdiskMgr = Join-Path $VMwareWorkstationDir 'vmware-vdiskmanager.exe'
|
||||
$vmDir = Split-Path -Parent $VMXPath
|
||||
if ([string]::IsNullOrWhiteSpace($SeedIsoPath)) {
|
||||
$SeedIsoPath = Join-Path $vmDir 'cloud-init-seed.iso'
|
||||
}
|
||||
$stagingDir = Join-Path $vmDir '_cloud_init_staging'
|
||||
$destVmdk = Join-Path $vmDir 'LinuxBuild2404.vmdk'
|
||||
$sshPubKeyPath = $SshKeyPath + '.pub'
|
||||
$guestIP = $null # set in Step 7; pre-init for strict-mode safety
|
||||
# guestinfo base64 payloads -- populated in Step 2, consumed in Step 5.
|
||||
# Initialized here so Set-StrictMode does not throw when Step 2 is skipped.
|
||||
$guestUserDataB64 = ''
|
||||
$guestMetaDataB64 = ''
|
||||
|
||||
if ($StartFromStep -gt 1) {
|
||||
Write-Host " [SKIP] Steps 1..$($StartFromStep - 1) -- starting from Step $StartFromStep" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Step 1: validate prerequisites + download cloud VMDK
|
||||
# =============================================================================
|
||||
Write-Step 'Step 1: validate prerequisites + download cloud VMDK' -Step 1
|
||||
if ($StartFromStep -le 1) {
|
||||
|
||||
Assert-Step 'PreFlight' "vmrun.exe found at $vmrunExe" { Test-Path $vmrunExe }
|
||||
Assert-Step 'PreFlight' "vmware-vdiskmanager.exe found at $vdiskMgr" { Test-Path $vdiskMgr }
|
||||
Assert-Step 'PreFlight' 'ssh.exe found in PATH' {
|
||||
$null -ne (Get-Command ssh -ErrorAction SilentlyContinue)
|
||||
}
|
||||
Assert-Step 'PreFlight' "SSH private key exists: $SshKeyPath" { Test-Path $SshKeyPath }
|
||||
Assert-Step 'PreFlight' "SSH public key exists: $sshPubKeyPath" { Test-Path $sshPubKeyPath }
|
||||
|
||||
if (Test-Path $vmDir) {
|
||||
if (-not $Force) {
|
||||
throw "[PreFlight] VM directory already exists: $vmDir`n Use -Force to overwrite."
|
||||
}
|
||||
Write-Host " [WARN] -Force set -- existing VM directory will be reused: $vmDir" -ForegroundColor Yellow
|
||||
} else {
|
||||
New-Item -ItemType Directory -Path $vmDir -Force | Out-Null
|
||||
}
|
||||
Assert-Step 'PreFlight' "VM directory ready: $vmDir" { Test-Path $vmDir }
|
||||
|
||||
# Step 1b -- Download cloud VMDK
|
||||
if (-not (Test-Path $VmdkCachePath)) {
|
||||
Write-Host "[Deploy] VMDK not found, downloading from $VmdkUrl ..."
|
||||
New-Item -ItemType Directory -Force (Split-Path $VmdkCachePath) | Out-Null
|
||||
$tmp = "$VmdkCachePath.part"
|
||||
try {
|
||||
Invoke-WebRequest -Uri $VmdkUrl -OutFile $tmp -UseBasicParsing
|
||||
Move-Item $tmp $VmdkCachePath
|
||||
Write-Host "[Deploy] Download complete: $VmdkCachePath"
|
||||
} catch {
|
||||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||
throw "[Deploy] VMDK download failed: $_"
|
||||
}
|
||||
} else {
|
||||
Write-Host "[Deploy] VMDK found in cache: $VmdkCachePath"
|
||||
}
|
||||
Assert-Step 'PreFlight' "Cloud VMDK cache present: $VmdkCachePath" { Test-Path $VmdkCachePath }
|
||||
|
||||
} # end Step 1
|
||||
|
||||
# =============================================================================
|
||||
# Step 2: generate cloud-init user-data + meta-data
|
||||
# =============================================================================
|
||||
Write-Step 'Step 2: generate cloud-init user-data + meta-data' -Step 2
|
||||
if ($StartFromStep -le 2) {
|
||||
|
||||
$pubKeyContent = (Get-Content -LiteralPath $sshPubKeyPath -Raw).Trim()
|
||||
|
||||
if (Test-Path $stagingDir) { Remove-Item $stagingDir -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null
|
||||
|
||||
$metaData = @"
|
||||
instance-id: linuxbuild-001
|
||||
local-hostname: $Hostname
|
||||
"@
|
||||
|
||||
$userData = @"
|
||||
#cloud-config
|
||||
hostname: $Hostname
|
||||
users:
|
||||
- name: $CloudUser
|
||||
shell: /bin/bash
|
||||
sudo: 'ALL=(ALL) NOPASSWD:ALL'
|
||||
ssh_authorized_keys:
|
||||
- $pubKeyContent
|
||||
"@
|
||||
|
||||
# Append chpasswd block when DiagPassword is provided (diagnostic use only).
|
||||
if ($DiagPassword -ne '') {
|
||||
Write-Host ' [WARN] -DiagPassword set: console login enabled for ubuntu user (diagnostic mode)' -ForegroundColor Yellow
|
||||
$userData += "`nchpasswd:`n list: |`n ubuntu:$DiagPassword`n expire: false`n"
|
||||
}
|
||||
|
||||
$metaDataPath = Join-Path $stagingDir 'meta-data'
|
||||
$userDataPath = Join-Path $stagingDir 'user-data'
|
||||
# PS 5.1 Set-Content -Encoding UTF8 writes a UTF-8 BOM. cloud-init checks that
|
||||
# user-data starts with exactly "#cloud-config"; a BOM prefix breaks this check
|
||||
# and the config is silently ignored. Write without BOM and with LF line endings.
|
||||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::WriteAllText($metaDataPath, ($metaData -replace "`r`n","`n"), $utf8NoBom)
|
||||
[System.IO.File]::WriteAllText($userDataPath, ($userData -replace "`r`n","`n"), $utf8NoBom)
|
||||
|
||||
# Pre-encode as base64 for guestinfo (used in Step 5 as backup datasource).
|
||||
$utf8NoBomEnc = [System.Text.UTF8Encoding]::new($false)
|
||||
$script:guestMetaDataB64 = [Convert]::ToBase64String($utf8NoBomEnc.GetBytes(($metaData -replace "`r`n","`n")))
|
||||
$script:guestUserDataB64 = [Convert]::ToBase64String($utf8NoBomEnc.GetBytes(($userData -replace "`r`n","`n")))
|
||||
|
||||
Assert-Step 'CloudInit' "meta-data written: $metaDataPath" { Test-Path $metaDataPath }
|
||||
Assert-Step 'CloudInit' "user-data written: $userDataPath" { Test-Path $userDataPath }
|
||||
Assert-Step 'CloudInit' 'user-data contains SSH public key' {
|
||||
$snippet = $pubKeyContent.Substring(0, [Math]::Min(30, $pubKeyContent.Length))
|
||||
(Get-Content -LiteralPath $userDataPath -Raw) -match [regex]::Escape($snippet)
|
||||
}
|
||||
|
||||
} # end Step 2
|
||||
|
||||
# =============================================================================
|
||||
# Step 3: build cloud-init seed ISO (nocloud, volume label: cidata)
|
||||
# =============================================================================
|
||||
Write-Step 'Step 3: build cloud-init seed ISO (nocloud, label: cidata)' -Step 3
|
||||
if ($StartFromStep -le 3) {
|
||||
|
||||
New-IsoFromFolder -SourceFolder $stagingDir -IsoPath $SeedIsoPath -VolumeLabel 'cidata'
|
||||
Assert-Step 'SeedIso' "Seed ISO created: $SeedIsoPath" { Test-Path $SeedIsoPath }
|
||||
Assert-Step 'SeedIso' 'Seed ISO non-empty (> 4 KB)' { (Get-Item $SeedIsoPath).Length -gt 4096 }
|
||||
|
||||
} # end Step 3
|
||||
|
||||
# =============================================================================
|
||||
# Step 4: copy + resize cloud VMDK
|
||||
# =============================================================================
|
||||
Write-Step "Step 4: copy + resize cloud VMDK to ${DiskSizeGB} GB" -Step 4
|
||||
if ($StartFromStep -le 4) {
|
||||
|
||||
Write-Host " Converting $VmdkCachePath -> $destVmdk (streamOptimized -> monolithicSparse) ..."
|
||||
& $vdiskMgr -r $VmdkCachePath -t 0 $destVmdk
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Deploy Step 4] vmware-vdiskmanager convert failed (exit $LASTEXITCODE)" }
|
||||
Assert-Step 'Vmdk' 'VMDK converted (size > 1 MB)' { (Get-Item $destVmdk).Length -gt 1MB }
|
||||
|
||||
Write-Host " Resizing VMDK to ${DiskSizeGB} GB ..."
|
||||
& $vdiskMgr -x "${DiskSizeGB}GB" $destVmdk
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Deploy Step 4] vmware-vdiskmanager resize failed (exit $LASTEXITCODE)" }
|
||||
Assert-Step 'Vmdk' "VMDK present after resize: $destVmdk" { Test-Path $destVmdk }
|
||||
|
||||
} # end Step 4
|
||||
|
||||
# =============================================================================
|
||||
# Step 5: generate VMX
|
||||
# =============================================================================
|
||||
Write-Step 'Step 5: generate VMX' -Step 5
|
||||
if ($StartFromStep -le 5) {
|
||||
|
||||
$vmxLines = @(
|
||||
'.encoding = "UTF-8"',
|
||||
'config.version = "8"',
|
||||
'virtualHW.version = "19"',
|
||||
"displayName = `"$VMName`"",
|
||||
'guestOS = "ubuntu-64"',
|
||||
'firmware = "efi"',
|
||||
"memsize = `"$MemoryMB`"",
|
||||
"numvcpus = `"$VCPUCount`"",
|
||||
"cpuid.coresPerSocket = `"$CoresPerSocket`"",
|
||||
'scsi0.present = "TRUE"',
|
||||
'scsi0.virtualDev = "lsilogic"',
|
||||
'scsi0:0.present = "TRUE"',
|
||||
'scsi0:0.fileName = "LinuxBuild2404.vmdk"',
|
||||
'scsi0:0.deviceType = "scsi-hardDisk"',
|
||||
'ethernet0.present = "TRUE"',
|
||||
'ethernet0.connectionType = "nat"',
|
||||
'ethernet0.virtualDev = "vmxnet3"',
|
||||
'ethernet0.wakeOnPcktRcv = "FALSE"',
|
||||
'ethernet0.addressType = "generated"',
|
||||
'ide1:0.present = "TRUE"',
|
||||
"ide1:0.fileName = `"$SeedIsoPath`"",
|
||||
'ide1:0.deviceType = "cdrom-image"',
|
||||
'ide1:0.startConnected = "TRUE"',
|
||||
'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"',
|
||||
'floppy0.present = "FALSE"',
|
||||
'usb.present = "FALSE"',
|
||||
'sound.present = "FALSE"',
|
||||
'tools.syncTime = "TRUE"',
|
||||
'uuid.action = "create"'
|
||||
)
|
||||
# Belt-and-suspenders: embed cloud-init payload as VMware guestinfo properties.
|
||||
# cloud-init DataSourceVMware reads these via open-vm-tools (pre-installed on
|
||||
# the Ubuntu 24.04 cloud image). This ensures provisioning works even if the
|
||||
# nocloud ISO is not detected by blkid (e.g. label case or filesystem type
|
||||
# mismatch). guestinfo is only added when the base64 strings were computed in
|
||||
# Step 2 (i.e. not skipped).
|
||||
if ($guestUserDataB64 -ne '' -and $guestMetaDataB64 -ne '') {
|
||||
$vmxLines += "guestinfo.userdata = `"$guestUserDataB64`""
|
||||
$vmxLines += 'guestinfo.userdata.encoding = "base64"'
|
||||
$vmxLines += "guestinfo.metadata = `"$guestMetaDataB64`""
|
||||
$vmxLines += 'guestinfo.metadata.encoding = "base64"'
|
||||
Write-Host " [INFO] guestinfo cloud-init properties embedded in VMX (backup datasource)."
|
||||
}
|
||||
Set-Content -LiteralPath $VMXPath -Value $vmxLines -Encoding ASCII
|
||||
Assert-Step 'Vmx' "VMX written: $VMXPath" { Test-Path $VMXPath }
|
||||
|
||||
} # end Step 5
|
||||
|
||||
# =============================================================================
|
||||
# Step 6: power on VM
|
||||
# =============================================================================
|
||||
Write-Step 'Step 6: power on VM' -Step 6
|
||||
if ($StartFromStep -le 6) {
|
||||
|
||||
$guiMode = if ($ShowGui) { 'gui' } else { 'nogui' }
|
||||
Invoke-Vmrun @('-T','ws','start',$VMXPath,$guiMode) -Async
|
||||
Write-Host "[Deploy] VM powered on (async)."
|
||||
Start-Sleep -Seconds 10
|
||||
Assert-Step 'PowerOn' 'VM appears in vmrun list' { Test-VmInList -VmxPath $VMXPath }
|
||||
|
||||
} # end Step 6
|
||||
|
||||
# =============================================================================
|
||||
# Step 7: detect guest IP via vmrun getGuestIPAddress (poll, max 5 min)
|
||||
# =============================================================================
|
||||
Write-Step 'Step 7: detect guest IP via vmrun getGuestIPAddress (max 5 min)' -Step 7
|
||||
if ($StartFromStep -le 7) {
|
||||
|
||||
$ipDeadline = (Get-Date).AddMinutes(5)
|
||||
$guestIP = $null
|
||||
while ((Get-Date) -lt $ipDeadline) {
|
||||
$ip = & $vmrunExe getGuestIPAddress $VMXPath 2>&1
|
||||
if ($ip -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') { $guestIP = $ip.Trim(); break }
|
||||
Write-Host " ... waiting for guest IP (open-vm-tools)..."
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
if (-not $guestIP) { throw "[Deploy Step 7] Timed out waiting for guest IP." }
|
||||
Write-Host " [OK] Guest IP: $guestIP" -ForegroundColor Green
|
||||
|
||||
} # end Step 7
|
||||
|
||||
# =============================================================================
|
||||
# Step 8: poll SSH port 22 until ready
|
||||
# =============================================================================
|
||||
Write-Step "Step 8: poll SSH port 22 (max ${SshTimeoutSeconds}s)" -Step 8
|
||||
if ($StartFromStep -le 8) {
|
||||
|
||||
if (-not $guestIP) {
|
||||
throw "[Deploy Step 8] Guest IP not available. Run from Step 7 or earlier to detect it."
|
||||
}
|
||||
|
||||
$sshDeadline = (Get-Date).AddSeconds($SshTimeoutSeconds)
|
||||
$sshReady = $false
|
||||
while ((Get-Date) -lt $sshDeadline) {
|
||||
$tc = Test-NetConnection -ComputerName $guestIP -Port 22 -WarningAction SilentlyContinue
|
||||
if ($tc.TcpTestSucceeded) { $sshReady = $true; break }
|
||||
Write-Host " ... waiting for SSH port 22 on $guestIP ..."
|
||||
Start-Sleep -Seconds $SshPollIntervalSeconds
|
||||
}
|
||||
Assert-Step 'SshPort' "SSH port 22 open on $guestIP" { $sshReady }
|
||||
|
||||
} # end Step 8
|
||||
|
||||
# =============================================================================
|
||||
# Step 9: verify cloud-init completed (boot-finished flag)
|
||||
# =============================================================================
|
||||
Write-Step 'Step 9: verify cloud-init completed (boot-finished flag)' -Step 9
|
||||
if ($StartFromStep -le 9) {
|
||||
|
||||
if (-not $guestIP) {
|
||||
throw "[Deploy Step 9] Guest IP not available. Run from Step 7 or earlier to detect it."
|
||||
}
|
||||
|
||||
# TCP:22 may open before cloud-init has finished creating ci_build and injecting
|
||||
# the SSH key. With package_update:true, apt can take 3-5 minutes.
|
||||
# Poll until SSH as ci_build succeeds AND boot-finished flag is present.
|
||||
$ciDeadline = (Get-Date).AddSeconds(600)
|
||||
$ciDone = $false
|
||||
Write-Host " Polling cloud-init completion (max 600s, package_update may take a few minutes) ..."
|
||||
while ((Get-Date) -lt $ciDeadline) {
|
||||
# In PS 5.1 with $ErrorActionPreference=Stop, any ssh stderr output (including
|
||||
# the benign "Permanently added" warning) throws NativeCommandError before the
|
||||
# assignment can capture it. Suppressing at the ssh level with LogLevel=ERROR
|
||||
# eliminates the warning entirely; temporarily lowering EAP to Continue means
|
||||
# auth failures ("permission denied") during the cloud-init bootstrap phase
|
||||
# are treated as non-terminating so the loop can keep retrying.
|
||||
$savedEap = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$rawOut = (& ssh -i $SshKeyPath `
|
||||
-o StrictHostKeyChecking=no `
|
||||
-o UserKnownHostsFile=NUL `
|
||||
-o LogLevel=ERROR `
|
||||
-o ConnectTimeout=5 `
|
||||
-o BatchMode=yes `
|
||||
"${CloudUser}@${guestIP}" `
|
||||
"test -f /var/lib/cloud/instance/boot-finished && echo OK" 2>&1)
|
||||
$sshExit = $LASTEXITCODE
|
||||
$ErrorActionPreference = $savedEap
|
||||
$result = ($rawOut | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | Out-String).Trim()
|
||||
$errText = ($rawOut | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | Out-String).Trim()
|
||||
if ($result -eq 'OK') { $ciDone = $true; break }
|
||||
$diagMsg = if ($errText) { $errText } elseif ($result) { $result } else { '(no output)' }
|
||||
Write-Host " ... not ready yet (ssh exit ${sshExit}: $diagMsg) -- retrying in 15s ..."
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
if (-not $ciDone) {
|
||||
throw "[Deploy Step 9] Timed out (600s) waiting for cloud-init to complete."
|
||||
}
|
||||
Write-Host " [OK] cloud-init completed." -ForegroundColor Green
|
||||
|
||||
} # end Step 9
|
||||
|
||||
# =============================================================================
|
||||
# Step 10: remove seed ISO from VMX (prevent re-run on clone)
|
||||
# =============================================================================
|
||||
Write-Step 'Step 10: remove seed ISO from VMX (prevent re-run on clone)' -Step 10
|
||||
if ($StartFromStep -le 10) {
|
||||
|
||||
Set-VmxKey -VmxPath $VMXPath -Key 'ide1:0.present' -Value 'FALSE'
|
||||
Set-VmxKey -VmxPath $VMXPath -Key 'ide1:0.startConnected' -Value 'FALSE'
|
||||
|
||||
Assert-Step 'SeedDetach' 'ide1:0.present = FALSE in VMX' {
|
||||
(Get-Content -LiteralPath $VMXPath) -match '^\s*ide1:0\.present\s*=\s*"FALSE"'
|
||||
}
|
||||
Assert-Step 'SeedDetach' 'ide1:0.startConnected = FALSE in VMX' {
|
||||
(Get-Content -LiteralPath $VMXPath) -match '^\s*ide1:0\.startConnected\s*=\s*"FALSE"'
|
||||
}
|
||||
|
||||
} # end Step 10
|
||||
|
||||
# =============================================================================
|
||||
# Cleanup staging dir
|
||||
# =============================================================================
|
||||
[GC]::Collect(); [GC]::WaitForPendingFinalizers(); [GC]::Collect()
|
||||
Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# =============================================================================
|
||||
# Final summary
|
||||
# =============================================================================
|
||||
Write-Step 'Done'
|
||||
Write-Host ""
|
||||
Write-Host "[Deploy] Summary"
|
||||
Write-Host " VMX: $VMXPath"
|
||||
Write-Host " Guest IP: $guestIP"
|
||||
Write-Host " SSH key: $SshKeyPath"
|
||||
Write-Host " Snapshot name: $SnapshotName (run Prepare-LinuxBuild2404.ps1 next)"
|
||||
Write-Host ""
|
||||
Write-Host " SSH: ssh -i $SshKeyPath ${CloudUser}@${guestIP}"
|
||||
Write-Host ""
|
||||
Write-Host " Next steps (from template\ dir):"
|
||||
Write-Host " .\Prepare-LinuxBuild2404.ps1 -VMXPath '$VMXPath' -VMIPAddress $guestIP"
|
||||
Write-Host ""
|
||||
Write-Host " VM is powered on. cloud-init provisioning complete." -ForegroundColor Green
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
HOST-side script: delivers and runs Setup-LinuxBuild2404.sh inside the Linux template VM.
|
||||
|
||||
.DESCRIPTION
|
||||
Automates provisioning of the Ubuntu 24.04 template VM from the host machine via SSH/SCP:
|
||||
1. Pre-flight validation: ssh/scp in PATH, SSH key present, VMX exists (if supplied),
|
||||
IPv4 address valid, TCP/22 reachable
|
||||
2. Auto power-on + guest IP detection via vmrun getGuestIPAddress (when -VMXPath provided)
|
||||
3. Add host key to known_hosts (idempotent, uses StrictHostKeyChecking=accept-new)
|
||||
4. Copy Setup-LinuxBuild2404.sh to /tmp on guest via scp + validate file size
|
||||
5. Execute Setup-LinuxBuild2404.sh via SSH (stdout/stderr streamed to host console)
|
||||
6. Post-setup remote validation batch check (user, sudo, tools, CI dirs, swap, sshd)
|
||||
7. Graceful shutdown + vmrun snapshot (when -TakeSnapshot or -VMXPath provided)
|
||||
Final: print summary
|
||||
|
||||
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
|
||||
The snapshot is only taken after this script exits 0.
|
||||
|
||||
PREREQUISITES (do these manually before running this script):
|
||||
a. The VM was created by Deploy-LinuxBuild2404.ps1 and cloud-init has completed
|
||||
b. The ci_build SSH public key ($SshKeyPath.pub) is already authorised in the guest
|
||||
(cloud-init in Deploy-LinuxBuild2404.ps1 injects the public key automatically)
|
||||
c. The VM NIC is on VMnet8 (NAT) with internet access
|
||||
|
||||
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
|
||||
If -TakeSnapshot was NOT set and -VMXPath was NOT provided:
|
||||
1. Shut down the VM manually
|
||||
2. Take a snapshot named '$SnapshotName' in VMware Workstation
|
||||
3. Use -SnapshotName '$SnapshotName' when calling New-BuildVM.ps1
|
||||
|
||||
.PARAMETER VMXPath
|
||||
Full path to the template VM's .vmx file.
|
||||
When provided:
|
||||
- If the VM is powered off, the script starts it automatically via vmrun.
|
||||
- The guest IP is auto-detected via vmrun getGuestIPAddress (requires open-vm-tools).
|
||||
- At the end, the VM is shut down and a snapshot is taken automatically.
|
||||
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 open-vm-tools).
|
||||
Required when -VMXPath is omitted.
|
||||
Example: 192.168.79.131
|
||||
|
||||
.PARAMETER CloudUser
|
||||
SSH username in the guest VM. Default: ci_build.
|
||||
|
||||
.PARAMETER SshKeyPath
|
||||
Path to the SSH private key on the host. Default: F:\CI\keys\ci_linux.
|
||||
The corresponding public key is expected at $SshKeyPath.pub.
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Name of the snapshot to create. Default: BaseClean-Linux.
|
||||
Must match the snapshot name used in New-BuildVM.ps1.
|
||||
|
||||
.PARAMETER SkipUpdate
|
||||
Pass --skip-update to Setup-LinuxBuild2404.sh (skips apt-get upgrade).
|
||||
|
||||
.PARAMETER InstallDotNet
|
||||
Pass --dotnet to Setup-LinuxBuild2404.sh (installs .NET SDK).
|
||||
|
||||
.PARAMETER NoMingw
|
||||
Pass --no-mingw to Setup-LinuxBuild2404.sh (skips MinGW cross-compiler).
|
||||
MinGW is installed by default; use this only to skip it.
|
||||
|
||||
.PARAMETER TakeSnapshot
|
||||
Shut down the VM and take a vmrun snapshot after provisioning completes.
|
||||
Implied automatically when -VMXPath is provided (snapshot is always taken
|
||||
when the script manages the VM lifecycle via vmrun).
|
||||
|
||||
.PARAMETER VMwareWorkstationDir
|
||||
Installation directory of VMware Workstation on the host.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation
|
||||
|
||||
.EXAMPLE
|
||||
# Fully automated — VMX path only (power-on + IP detection + snapshot automatic):
|
||||
.\Prepare-LinuxBuild2404.ps1 -VMXPath 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx'
|
||||
|
||||
# Explicit IP, skip update (VM already running, no snapshot):
|
||||
.\Prepare-LinuxBuild2404.ps1 -VMIPAddress '192.168.79.131' -SkipUpdate
|
||||
|
||||
# VMX + .NET SDK + explicit snapshot:
|
||||
.\Prepare-LinuxBuild2404.ps1 `
|
||||
-VMXPath 'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx' `
|
||||
-InstallDotNet -TakeSnapshot
|
||||
|
||||
.NOTES
|
||||
Requires:
|
||||
- OpenSSH client on the host (ssh.exe + scp.exe in PATH)
|
||||
- VMware Workstation with vmrun.exe (only needed when -VMXPath is supplied)
|
||||
- open-vm-tools installed in the guest (for vmrun getGuestIPAddress)
|
||||
- The SSH private key at $SshKeyPath with correct permissions
|
||||
#>
|
||||
[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 open-vm-tools).
|
||||
[string] $VMIPAddress = '',
|
||||
|
||||
[string] $CloudUser = 'ci_build',
|
||||
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
# Snapshot name (case-sensitive — must match the name used in New-BuildVM.ps1)
|
||||
[string] $SnapshotName = 'BaseClean-Linux',
|
||||
|
||||
[switch] $SkipUpdate,
|
||||
|
||||
[switch] $InstallDotNet,
|
||||
|
||||
[switch] $NoMingw,
|
||||
|
||||
# Shut down VM and take snapshot after provisioning (implied when -VMXPath is provided)
|
||||
[switch] $TakeSnapshot,
|
||||
|
||||
[string] $VMwareWorkstationDir = 'C:\Program Files (x86)\VMware\VMware Workstation'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Locate vmrun.exe ──────────────────────────────────────────────────────────
|
||||
$vmrunExe = Join-Path $VMwareWorkstationDir 'vmrun.exe'
|
||||
if (-not (Test-Path $vmrunExe)) {
|
||||
# Fallback: try the 64-bit install path or PATH
|
||||
$alt64 = 'C:\Program Files\VMware\VMware Workstation\vmrun.exe'
|
||||
if (Test-Path $alt64) {
|
||||
$vmrunExe = $alt64
|
||||
} else {
|
||||
$vmrunInPath = Get-Command vmrun -ErrorAction SilentlyContinue
|
||||
if ($vmrunInPath) {
|
||||
$vmrunExe = $vmrunInPath.Source
|
||||
} else {
|
||||
throw "[Prepare] vmrun.exe not found at '$vmrunExe'. Install VMware Workstation or add it to PATH."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ── Validate input: need VMXPath or VMIPAddress ───────────────────────────────
|
||||
if ($VMXPath -eq '' -and $VMIPAddress -eq '') {
|
||||
throw "[Prepare] Supply -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)."
|
||||
}
|
||||
|
||||
# ── Auto power-on + IP detection (when VMXPath provided) ─────────────────────
|
||||
if ($VMXPath -ne '') {
|
||||
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" }
|
||||
|
||||
$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 nogui
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start failed (exit $LASTEXITCODE)." }
|
||||
Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..."
|
||||
} else {
|
||||
Write-Host "[Prepare] VM already running: $VMXPath"
|
||||
}
|
||||
|
||||
if ($VMIPAddress -eq '') {
|
||||
Write-Host "[Prepare] Detecting guest IP via open-vm-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 open-vm-tools is running in the guest."
|
||||
}
|
||||
$VMIPAddress = $detectedIP
|
||||
Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function Invoke-SshCommand {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $IP,
|
||||
[Parameter(Mandatory)] [string] $User,
|
||||
[Parameter(Mandatory)] [string] $KeyPath,
|
||||
[Parameter(Mandatory)] [string] $Command,
|
||||
[switch] $PassThru # return stdout instead of printing
|
||||
)
|
||||
$sshArgs = @(
|
||||
'-i', $KeyPath,
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=NUL',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'ConnectTimeout=10',
|
||||
'-o', 'BatchMode=yes',
|
||||
"$User@$IP",
|
||||
$Command
|
||||
)
|
||||
if ($PassThru) {
|
||||
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
$out = & ssh @sshArgs 2>&1
|
||||
$sshRc = $LASTEXITCODE
|
||||
$ErrorActionPreference = $savedEap
|
||||
if ($sshRc -ne 0) { throw "[SSH] Command failed (exit $sshRc): $Command`nOutput: $out" }
|
||||
return ($out | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] })
|
||||
} else {
|
||||
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
& ssh @sshArgs
|
||||
$sshRc = $LASTEXITCODE
|
||||
$ErrorActionPreference = $savedEap
|
||||
if ($sshRc -ne 0) { throw "[SSH] Command failed (exit $sshRc): $Command" }
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 1 — Pre-flight validation ───────────────────────────────────────────
|
||||
Write-Host "[Prepare] Step 1 — Pre-flight validation"
|
||||
|
||||
Assert-Step 'PreFlight' 'ssh.exe found in PATH' {
|
||||
$null -ne (Get-Command ssh -ErrorAction SilentlyContinue)
|
||||
}
|
||||
Assert-Step 'PreFlight' 'scp.exe found in PATH' {
|
||||
$null -ne (Get-Command scp -ErrorAction SilentlyContinue)
|
||||
}
|
||||
Assert-Step 'PreFlight' "SSH private key exists: $SshKeyPath" {
|
||||
Test-Path $SshKeyPath -PathType Leaf
|
||||
}
|
||||
Assert-Step 'PreFlight' "SSH public key exists: $SshKeyPath.pub" {
|
||||
Test-Path "$SshKeyPath.pub" -PathType Leaf
|
||||
}
|
||||
if ($VMXPath -ne '') {
|
||||
Assert-Step 'PreFlight' "VMX file exists: $VMXPath" {
|
||||
Test-Path $VMXPath -PathType Leaf
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 2 — Validate IP address ─────────────────────────────────────────────
|
||||
Write-Host "[Prepare] Step 2 — Validate guest IP address"
|
||||
Assert-Step 'IPValidation' "VMIPAddress '$VMIPAddress' matches IPv4 pattern" {
|
||||
$VMIPAddress -match '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'
|
||||
}
|
||||
Write-Host " Guest IP: $VMIPAddress"
|
||||
|
||||
# ── Step 3 — Wait for SSH port 22 ────────────────────────────────────────────
|
||||
Write-Host "[Prepare] Step 3 — Waiting for SSH port 22 on $VMIPAddress (timeout 180 s)..."
|
||||
$sshDeadline = (Get-Date).AddSeconds(180)
|
||||
$sshReady = $false
|
||||
while ((Get-Date) -lt $sshDeadline) {
|
||||
if (Test-NetConnection -ComputerName $VMIPAddress -Port 22 `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
|
||||
$sshReady = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 10
|
||||
Write-Host " ... waiting for SSH..."
|
||||
}
|
||||
Assert-Step 'SSHPort' "$VMIPAddress TCP/22 reachable" { $sshReady }
|
||||
|
||||
# ── Step 4 — Add host key to known_hosts ─────────────────────────────────────
|
||||
Write-Host "[Prepare] Step 4 — Adding host key to known_hosts (StrictHostKeyChecking=accept-new)..."
|
||||
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
& ssh -i $SshKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL -o LogLevel=ERROR -o ConnectTimeout=10 `
|
||||
-o BatchMode=yes "$CloudUser@$VMIPAddress" 'echo connected' 2>&1 | Out-Null
|
||||
$ErrorActionPreference = $savedEap
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "[Prepare Step 4] SSH connectivity test failed (exit $LASTEXITCODE). Check key, user, and IP."
|
||||
}
|
||||
Write-Host " [OK] SSH connectivity confirmed, host key added."
|
||||
|
||||
# ── Step 5 — Copy Setup-LinuxBuild2404.sh into VM ────────────────────────────
|
||||
Write-Host "[Prepare] Step 5 — Copying Setup-LinuxBuild2404.sh to guest..."
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$setupScript = Join-Path $scriptDir 'Setup-LinuxBuild2404.sh'
|
||||
$guestScript = '/tmp/Setup-LinuxBuild2404.sh'
|
||||
|
||||
if (-not (Test-Path $setupScript -PathType Leaf)) {
|
||||
throw "[Prepare Step 5] Setup-LinuxBuild2404.sh not found alongside this script: $setupScript"
|
||||
}
|
||||
|
||||
# Strip UTF-8 BOM and normalize CRLF→LF before copying.
|
||||
# PS 5.1 / VS Code on Windows may save .sh files with BOM or CRLF; both break bash.
|
||||
$shBytes = [System.IO.File]::ReadAllBytes($setupScript)
|
||||
if ($shBytes.Length -ge 3 -and $shBytes[0] -eq 0xEF -and $shBytes[1] -eq 0xBB -and $shBytes[2] -eq 0xBF) {
|
||||
Write-Host " [WARN] UTF-8 BOM detected in Setup-LinuxBuild2404.sh — stripping before copy"
|
||||
$shBytes = $shBytes[3..($shBytes.Length - 1)]
|
||||
}
|
||||
$shText = [System.Text.Encoding]::UTF8.GetString($shBytes) -replace "`r`n", "`n" -replace "`r", "`n"
|
||||
$tmpScript = Join-Path $env:TEMP 'Setup-LinuxBuild2404-ci.sh'
|
||||
[System.IO.File]::WriteAllText($tmpScript, $shText, [System.Text.UTF8Encoding]::new($false))
|
||||
$setupScript = $tmpScript
|
||||
|
||||
Write-Host " Source: $setupScript (BOM/CRLF-cleaned)"
|
||||
Write-Host " Dest: $guestScript"
|
||||
|
||||
$savedEap = $ErrorActionPreference; $ErrorActionPreference = 'Continue'
|
||||
& scp -i $SshKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL -o LogLevel=ERROR `
|
||||
$setupScript "${CloudUser}@${VMIPAddress}:${guestScript}" 2>&1 | Out-Null
|
||||
$scpRc = $LASTEXITCODE
|
||||
$ErrorActionPreference = $savedEap
|
||||
if ($scpRc -ne 0) { throw "[Prepare Step 5] scp failed (exit $scpRc)." }
|
||||
|
||||
# Validate: compare local size vs remote size (allow +-2 bytes for LF/CRLF variance)
|
||||
$localSize = (Get-Item $setupScript).Length
|
||||
$remoteSizeRaw = (Invoke-SshCommand -IP $VMIPAddress -User $CloudUser -KeyPath $SshKeyPath `
|
||||
-Command "stat -c %s $guestScript" -PassThru).Trim()
|
||||
$remoteSize = [int]$remoteSizeRaw
|
||||
|
||||
Assert-Step 'FileCopy' "guest script size matches local ($localSize bytes, remote $remoteSize bytes, tolerance 2)" {
|
||||
[Math]::Abs($remoteSize - $localSize) -le 2
|
||||
}
|
||||
|
||||
# ── Step 6 — Execute setup script ────────────────────────────────────────────
|
||||
Write-Host "[Prepare] Step 6 — Running Setup-LinuxBuild2404.sh in guest (may take several minutes)..."
|
||||
|
||||
$setupFlags = [System.Collections.Generic.List[string]]::new()
|
||||
if ($SkipUpdate) { $setupFlags.Add('--skip-update') }
|
||||
if ($InstallDotNet) { $setupFlags.Add('--dotnet') }
|
||||
if ($NoMingw) { $setupFlags.Add('--no-mingw') }
|
||||
$flagsStr = $setupFlags -join ' '
|
||||
$remoteCmd = "chmod +x $guestScript && sudo $guestScript $flagsStr".TrimEnd()
|
||||
|
||||
Write-Host " Command: $remoteCmd"
|
||||
Write-Host "------------------------------------------------------------"
|
||||
|
||||
& ssh -i $SshKeyPath -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL -o ConnectTimeout=5 `
|
||||
-o BatchMode=yes "$CloudUser@$VMIPAddress" $remoteCmd
|
||||
$setupExitCode = $LASTEXITCODE
|
||||
|
||||
Write-Host "------------------------------------------------------------"
|
||||
if ($setupExitCode -ne 0) {
|
||||
throw "[Prepare Step 6] Setup-LinuxBuild2404.sh exited $setupExitCode — see output above."
|
||||
}
|
||||
Write-Host "[Prepare] Setup script completed (exit 0)." -ForegroundColor Green
|
||||
|
||||
# ── Step 7 — Post-setup remote validation ────────────────────────────────────
|
||||
Write-Host "[Prepare] Step 7 — Running post-setup validation..."
|
||||
|
||||
$validationScript = @'
|
||||
set -e
|
||||
( id ci_build | grep -q sudo || sudo -l -U ci_build 2>/dev/null | grep -q NOPASSWD ) && echo "UserSudo:OK" || echo "UserSudo:FAIL"
|
||||
sudo -n true 2>/dev/null && echo "SudoNoPass:OK" || echo "SudoNoPass:FAIL"
|
||||
for t in gcc g++ clang cmake python3 git 7z; do command -v "$t" > /dev/null && echo "Tool:$t:OK" || echo "Tool:$t:FAIL"; done
|
||||
test -d /opt/ci/build && echo "Dir:build:OK" || echo "Dir:build:FAIL"
|
||||
test -d /opt/ci/output && echo "Dir:output:OK" || echo "Dir:output:FAIL"
|
||||
test -d /opt/ci/scripts && echo "Dir:scripts:OK" || echo "Dir:scripts:FAIL"
|
||||
swapon --show | grep -q . && echo "Swap:ACTIVE:FAIL" || echo "Swap:off:OK"
|
||||
systemctl is-active ssh > /dev/null && echo "SSHD:active:OK" || echo "SSHD:inactive:FAIL"
|
||||
grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config && echo "SSHPwdAuth:disabled:OK" || echo "SSHPwdAuth:enabled:FAIL"
|
||||
systemctl is-enabled ci-report-ip.service > /dev/null 2>&1 && echo "CIReportIP:enabled:OK" || echo "CIReportIP:enabled:FAIL"
|
||||
test -x /usr/local/bin/ci-report-ip.sh && echo "CIReportIPScript:exists:OK" || echo "CIReportIPScript:exists:FAIL"
|
||||
'@
|
||||
|
||||
$results = Invoke-SshCommand -IP $VMIPAddress -User $CloudUser -KeyPath $SshKeyPath `
|
||||
-Command $validationScript -PassThru
|
||||
|
||||
Write-Host "[Prepare] Validation results:"
|
||||
$results | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
$failures = @($results | Where-Object { $_ -match ':FAIL' })
|
||||
if ($failures.Count -gt 0) {
|
||||
throw "[Prepare Step 7] Post-setup validation failed:`n$($failures -join "`n")"
|
||||
}
|
||||
Write-Host "[Prepare] All post-setup checks passed." -ForegroundColor Green
|
||||
|
||||
# ── Step 8 — Shutdown + snapshot ─────────────────────────────────────────────
|
||||
if ($TakeSnapshot -or $VMXPath -ne '') {
|
||||
Write-Host "[Prepare] Step 8 — Sending graceful shutdown to guest..."
|
||||
try {
|
||||
Invoke-SshCommand -IP $VMIPAddress -User $CloudUser -KeyPath $SshKeyPath `
|
||||
-Command 'sudo shutdown -h now' -PassThru | Out-Null
|
||||
} catch {
|
||||
Write-Warning "[Prepare] Shutdown command may have failed (VM may have already started shutting down): $_"
|
||||
}
|
||||
|
||||
if ($VMXPath -ne '') {
|
||||
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
|
||||
$deadline = (Get-Date).AddSeconds(120)
|
||||
$poweredOff = $false
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$listOut = & $vmrunExe list 2>&1 | Out-String
|
||||
if ($listOut -notmatch [regex]::Escape($VMXPath)) {
|
||||
$poweredOff = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host " ... waiting for shutdown..."
|
||||
}
|
||||
if (-not $poweredOff) { throw "[Prepare Step 8] VM did not power off within 120 s." }
|
||||
|
||||
# Delete existing snapshot with the same name so vmrun can create a fresh one.
|
||||
$existingSnaps = (& $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String)
|
||||
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
|
||||
Write-Host "[Prepare] Snapshot '$SnapshotName' already exists — deleting before recreating..."
|
||||
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare Step 8] vmrun deleteSnapshot failed (exit $LASTEXITCODE)." }
|
||||
Write-Host "[Prepare] Old snapshot deleted."
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] Taking snapshot '$SnapshotName'..."
|
||||
& $vmrunExe snapshot $VMXPath $SnapshotName
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare Step 8] vmrun snapshot failed (exit $LASTEXITCODE)." }
|
||||
|
||||
Assert-Step 'Snapshot' "snapshot '$SnapshotName' listed in vmrun listSnapshots" {
|
||||
$snaps = (& $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String)
|
||||
$snaps -match [regex]::Escape($SnapshotName)
|
||||
}
|
||||
Write-Host "[Prepare] Snapshot '$SnapshotName' created." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[Prepare] -VMXPath not provided — snapshot skipped. Shut down and snapshot manually."
|
||||
}
|
||||
} else {
|
||||
Write-Host "[Prepare] -TakeSnapshot not set — leaving VM running. Shut down and snapshot manually when ready."
|
||||
}
|
||||
|
||||
# ── Final — Print summary ─────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "[Prepare] Summary" -ForegroundColor Cyan
|
||||
Write-Host " Guest IP: $VMIPAddress"
|
||||
Write-Host " SSH key: $SshKeyPath"
|
||||
if ($VMXPath -ne '') { Write-Host " VMX: $VMXPath" }
|
||||
if ($TakeSnapshot -or $VMXPath -ne '') {
|
||||
Write-Host " Snapshot: $SnapshotName — ready for New-BuildVM.ps1 -SnapshotName '$SnapshotName'"
|
||||
} else {
|
||||
Write-Host " Snapshot: not taken — run manually then use -SnapshotName '$SnapshotName'"
|
||||
}
|
||||
@@ -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,668 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
HOST-side script: delivers and runs Setup-WinBuild2022.ps1 inside the template VM.
|
||||
|
||||
.DESCRIPTION
|
||||
Automates the template VM provisioning from the host machine:
|
||||
1. Pre-flight validation: script presence, IP octet range, TCP/5986 reachable
|
||||
2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) — restored on exit
|
||||
3. Validates host WinRM TrustedHosts applied correctly
|
||||
4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck — fails fast with actionable message
|
||||
(PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe)
|
||||
5. PSSession open — step 4 and 5 are now merged
|
||||
6. Ensures C:\CI exists on guest; validates creation
|
||||
7. Copies Setup-WinBuild2022.ps1 into the VM; validates file present + size match
|
||||
8. Runs Setup-WinBuild2022.ps1 inside the VM with the given parameters
|
||||
(Setup performs per-step validation internally — aborts on any failure)
|
||||
9. Handles the reboot-required case (exit 3010) and optionally re-runs
|
||||
10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command
|
||||
(WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs)
|
||||
11. Restores host TrustedHosts in finally block
|
||||
|
||||
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
|
||||
The snapshot should only be taken after this script exits 0.
|
||||
|
||||
PREREQUISITES (do these manually before running this script):
|
||||
a. VM NIC is set to VMnet8 (NAT) — the VM needs internet for downloads
|
||||
b. Windows Server is installed and booted
|
||||
c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run:
|
||||
winrm quickconfig -q
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
d. You know the IP address the VM got from DHCP on VMnet8 (NAT)
|
||||
(check via: ipconfig inside the VM, or VMware → VM → Settings →
|
||||
Network Adapter → Advanced → MAC address, then check DHCP leases)
|
||||
|
||||
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
|
||||
1. Shut down the VM
|
||||
2. Take snapshot: VM → Snapshot → Take Snapshot → name: BaseClean
|
||||
(VM stays on VMnet8 NAT — internet access is required for builds)
|
||||
3. Store credentials on host (use the same password chosen during provisioning):
|
||||
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
|
||||
-Password '<your-build-password>' -Persist LocalMachine
|
||||
|
||||
.PARAMETER VMXPath
|
||||
Full path to the template VM's .vmx file (e.g. F:\CI\Templates\WinBuild\WinBuild.vmx).
|
||||
When provided:
|
||||
- If the VM is powered off, the script starts it automatically via vmrun.
|
||||
- The guest IP is detected automatically via vmrun getGuestIPAddress (requires
|
||||
VMware Tools installed in the guest).
|
||||
- At the end, the snapshot is created automatically after provisioning.
|
||||
Either -VMXPath or -VMIPAddress (or both) must be supplied.
|
||||
|
||||
.PARAMETER VMIPAddress
|
||||
IP address of the template VM on VMnet8 NAT.
|
||||
Optional when -VMXPath is provided (IP is auto-detected via VMware Tools).
|
||||
Required when -VMXPath is omitted.
|
||||
Example: 192.168.79.130
|
||||
|
||||
.PARAMETER AdminUsername
|
||||
Administrator username inside the VM. Default: Administrator
|
||||
|
||||
.PARAMETER AdminPassword
|
||||
Administrator password inside the VM. If not supplied, prompts interactively.
|
||||
|
||||
.PARAMETER BuildPassword
|
||||
Password to set for the ci_build user inside the VM.
|
||||
Must not be empty — script prompts interactively if not supplied.
|
||||
|
||||
.PARAMETER BuildUsername
|
||||
Local username for the CI build account inside the VM. Default: ci_build.
|
||||
Passed through to Setup-WinBuild2022.ps1 and used in post-setup validation.
|
||||
|
||||
.PARAMETER DotNetSdkVersion
|
||||
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
|
||||
|
||||
.PARAMETER SkipWindowsUpdate
|
||||
Pass through to Setup-WinBuild2022.ps1 to skip the Windows Update step.
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Name of the snapshot to create. Default: BaseClean (case-sensitive — used by New-BuildVM.ps1).
|
||||
Only relevant when -TakeSnapshot is set.
|
||||
|
||||
.PARAMETER StoreCredential
|
||||
After provisioning completes, store BuildUsername/BuildPassword in Windows
|
||||
Credential Manager (target: CredentialTarget). Installs the CredentialManager
|
||||
module (CurrentUser scope) automatically if not present.
|
||||
Equivalent to running manually:
|
||||
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
|
||||
-Password '<pwd>' -Persist LocalMachine
|
||||
|
||||
.PARAMETER CredentialTarget
|
||||
Windows Credential Manager target name used when -StoreCredential is set.
|
||||
Default: BuildVMGuest (matches the target expected by Invoke-CIJob.ps1 and
|
||||
the other CI scripts that load guest credentials via Get-StoredCredential).
|
||||
|
||||
.EXAMPLE
|
||||
# Fully automated — VMX path only (power-on + IP detection + snapshot automatic):
|
||||
.\Prepare-WinBuild2022.ps1 `
|
||||
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
|
||||
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
||||
|
||||
# Skip Windows Update (already applied):
|
||||
.\Prepare-WinBuild2022.ps1 `
|
||||
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
|
||||
-SkipWindowsUpdate -StoreCredential
|
||||
|
||||
# Explicit IP (manual start, no VMware Tools required for IP detection):
|
||||
.\Prepare-WinBuild2022.ps1 -VMIPAddress 192.168.79.130 `
|
||||
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
||||
|
||||
# Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress:
|
||||
.\Prepare-WinBuild2022.ps1 `
|
||||
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' -VMIPAddress 192.168.79.130 `
|
||||
-BuildPassword 'StrongCIPass!2026' -StoreCredential
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot.
|
||||
# Either -VMXPath or -VMIPAddress (or both) must be supplied.
|
||||
[string] $VMXPath = '',
|
||||
|
||||
# Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via VMware Tools).
|
||||
[string] $VMIPAddress = '',
|
||||
|
||||
[string] $AdminUsername = 'Administrator',
|
||||
|
||||
[string] $AdminPassword = '',
|
||||
|
||||
[string] $BuildPassword = '',
|
||||
|
||||
[string] $BuildUsername = 'ci_build',
|
||||
|
||||
[string] $DotNetSdkVersion = '10.0',
|
||||
|
||||
[switch] $SkipWindowsUpdate,
|
||||
|
||||
# Skip disk cleanup inside the VM (cleanmgr + DISM + cache clear) — speeds up re-runs
|
||||
[switch] $SkipCleanup,
|
||||
|
||||
# Snapshot name (case-sensitive — must match GITEA_CI_SNAPSHOT_NAME in runner/config.yaml)
|
||||
[string] $SnapshotName = 'BaseClean',
|
||||
|
||||
# Store BuildUsername/BuildPassword in Windows Credential Manager after provisioning
|
||||
[switch] $StoreCredential,
|
||||
|
||||
# Credential Manager target name (must match what CI scripts use via Get-StoredCredential)
|
||||
[string] $CredentialTarget = 'BuildVMGuest'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Locate vmrun.exe ──────────────────────────────────────────────────────────
|
||||
$vmrunCandidates = @(
|
||||
'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
||||
'C:\Program Files\VMware\VMware Workstation\vmrun.exe'
|
||||
)
|
||||
$vmrunExe = $vmrunCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $vmrunExe) {
|
||||
$vmrunFound = Get-Command vmrun -ErrorAction SilentlyContinue
|
||||
if ($vmrunFound) { $vmrunExe = $vmrunFound.Source }
|
||||
}
|
||||
if (-not $vmrunExe) { throw "[Prepare] vmrun.exe not found. Install VMware Workstation or add it to PATH." }
|
||||
|
||||
# ── Validate input: need VMXPath or VMIPAddress ───────────────────────────────
|
||||
if ($VMXPath -eq '' -and $VMIPAddress -eq '') {
|
||||
throw "[Prepare] Provide -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)."
|
||||
}
|
||||
|
||||
# ── Power on VM and detect IP via VMXPath ────────────────────────────────────
|
||||
$vmWasPoweredOn = $false
|
||||
if ($VMXPath -ne '') {
|
||||
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" }
|
||||
|
||||
# Check if already running
|
||||
$runningList = @(& $vmrunExe list 2>&1 |
|
||||
Where-Object { $_ -match '\.vmx$' } | ForEach-Object { $_.Trim() })
|
||||
|
||||
if ($runningList -notcontains $VMXPath) {
|
||||
Write-Host "[Prepare] VM not running — starting: $VMXPath"
|
||||
& $vmrunExe start $VMXPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start exited $LASTEXITCODE." }
|
||||
$vmWasPoweredOn = $true
|
||||
Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..."
|
||||
}
|
||||
else {
|
||||
Write-Host "[Prepare] VM already running: $VMXPath"
|
||||
}
|
||||
|
||||
# Auto-detect IP if not supplied
|
||||
if ($VMIPAddress -eq '') {
|
||||
Write-Host "[Prepare] Detecting guest IP via VMware Tools (getGuestIPAddress -wait)..."
|
||||
$detectedIP = (& $vmrunExe getGuestIPAddress $VMXPath -wait 2>&1).Trim()
|
||||
if ($detectedIP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
|
||||
throw "[Prepare] Could not get guest IP. Got: '$detectedIP'. Ensure VMware Tools is installed."
|
||||
}
|
||||
$VMIPAddress = $detectedIP
|
||||
Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Step {
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $StepName,
|
||||
[Parameter(Mandatory)] [string] $Description,
|
||||
[Parameter(Mandatory)] [scriptblock] $Test
|
||||
)
|
||||
$ok = $false
|
||||
try { $ok = [bool](& $Test) }
|
||||
catch { throw "[Prepare/$StepName] Validation threw on '$Description': $_" }
|
||||
if (-not $ok) {
|
||||
throw "[Prepare/$StepName] Validation failed: $Description"
|
||||
}
|
||||
Write-Host " [OK] $Description" -ForegroundColor Green
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
# Pre-flight validation
|
||||
Assert-Step 'PreFlight' 'script directory resolved' { Test-Path $scriptDir -PathType Container }
|
||||
Assert-Step 'PreFlight' 'Setup-WinBuild2022.ps1 present alongside this script' {
|
||||
Test-Path (Join-Path $scriptDir 'Setup-WinBuild2022.ps1') -PathType Leaf
|
||||
}
|
||||
Assert-Step 'PreFlight' "VMIPAddress octets in 0..255" {
|
||||
$parts = $VMIPAddress.Split('.') | ForEach-Object { [int]$_ }
|
||||
($parts.Count -eq 4) -and -not ($parts | Where-Object { $_ -lt 0 -or $_ -gt 255 })
|
||||
}
|
||||
|
||||
# WinRM readiness — retry loop (longer timeout if VM was just powered on by this script)
|
||||
$winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 }
|
||||
Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5986 (timeout ${winrmTimeoutSec}s)..."
|
||||
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
|
||||
$winrmReady = $false
|
||||
while ([datetime]::UtcNow -lt $winrmDeadline) {
|
||||
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
|
||||
$winrmReady = $true; break
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host " ... waiting for WinRM..."
|
||||
}
|
||||
Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady }
|
||||
|
||||
# Prompt for BuildPassword if not supplied — never use a hardcoded default.
|
||||
if ($BuildPassword -eq '') {
|
||||
Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:"
|
||||
$secPwd = Read-Host -Prompt " BuildPassword" -AsSecureString
|
||||
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
|
||||
$BuildPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||||
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
|
||||
# ── Build PSCredential ────────────────────────────────────────────────────────
|
||||
if ($AdminPassword -eq '') {
|
||||
Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):"
|
||||
$credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)"
|
||||
}
|
||||
else {
|
||||
$secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force
|
||||
$credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd)
|
||||
}
|
||||
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
# ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ──────
|
||||
# HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts
|
||||
# needs appending so PS accepts the non-domain VM. Restored in the finally block.
|
||||
$prevTrustedHosts = $null
|
||||
try {
|
||||
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
|
||||
# Add VM IP to TrustedHosts if not already present
|
||||
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
|
||||
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
|
||||
}
|
||||
Write-Host "[Prepare] Host TrustedHosts configured."
|
||||
Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" {
|
||||
$th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
||||
$th -eq '*' -or $th -like "*$VMIPAddress*"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)."
|
||||
Write-Warning "Run once in an elevated PowerShell on the HOST:"
|
||||
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
|
||||
Write-Warning "Then re-run this script."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
# ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ──
|
||||
# New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert.
|
||||
Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..."
|
||||
$session = try {
|
||||
New-PSSession `
|
||||
-ComputerName $VMIPAddress `
|
||||
-Credential $credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-UseSSL `
|
||||
-Port 5986 `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
} catch {
|
||||
Write-Error @"
|
||||
[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
|
||||
|
||||
Ensure:
|
||||
- The VM is running and on VMnet8 (NAT) with internet access
|
||||
- WinRM HTTPS is enabled inside the VM — Deploy-WinBuild2022.ps1 post-install.ps1
|
||||
must have run successfully (creates self-signed cert + HTTPS listener on 5986)
|
||||
- Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest)
|
||||
- The IP is correct (check ipconfig inside the VM)
|
||||
|
||||
Error: $_
|
||||
"@
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
# ── Copy Setup-WinBuild2022.ps1 into the VM ───────────────────────────────
|
||||
$setupScript = Join-Path $scriptDir 'Setup-WinBuild2022.ps1'
|
||||
$guestScriptPath = 'C:\CI\Setup-WinBuild2022.ps1'
|
||||
|
||||
Write-Host "[Prepare] Ensuring C:\CI exists on guest..."
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
if (-not (Test-Path 'C:\CI')) {
|
||||
New-Item -ItemType Directory -Path 'C:\CI' -Force | Out-Null
|
||||
}
|
||||
}
|
||||
Assert-Step 'GuestPrep' 'C:\CI exists on guest' {
|
||||
Invoke-Command -Session $session -ScriptBlock { Test-Path 'C:\CI' -PathType Container }
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] Copying Setup-WinBuild2022.ps1 -> guest $guestScriptPath (UTF-8 BOM)..."
|
||||
# Do NOT use Copy-Item -ToSession: it binary-copies the file.
|
||||
# If the source file has no UTF-8 BOM, PowerShell 5.1 on the guest reads it as
|
||||
# Windows-1252, misinterpreting multi-byte UTF-8 sequences (e.g. em dash U+2014
|
||||
# encodes as E2 80 94; byte 0x94 in Win-1252 = curly right-quote, which PS 5.1
|
||||
# treats as a string terminator → parse errors throughout the script).
|
||||
#
|
||||
# Fix: read content on host as Unicode string, transmit via WinRM (XML/Unicode),
|
||||
# write on guest with explicit UTF-8 BOM via System.Text.UTF8Encoding($true).
|
||||
# PS 5.1 detects the BOM and reads the file as UTF-8 correctly.
|
||||
$scriptContent = [System.IO.File]::ReadAllText($setupScript, [System.Text.Encoding]::UTF8)
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($content, $path)
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true) # $true = emit BOM
|
||||
[System.IO.File]::WriteAllText($path, $content, $utf8Bom)
|
||||
} -ArgumentList $scriptContent, $guestScriptPath
|
||||
|
||||
# Validation — file present and large enough (BOM transfer changes byte count vs host)
|
||||
$hostSize = (Get-Item $setupScript).Length
|
||||
Assert-Step 'GuestPrep' "guest script present at $guestScriptPath" {
|
||||
Invoke-Command -Session $session -ScriptBlock { param($p) Test-Path $p -PathType Leaf } -ArgumentList $guestScriptPath
|
||||
}
|
||||
Assert-Step 'GuestPrep' "guest script size reasonable (host=$hostSize bytes ±6 for BOM)" {
|
||||
$remoteSize = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($p) (Get-Item $p -ErrorAction SilentlyContinue).Length
|
||||
} -ArgumentList $guestScriptPath
|
||||
# UTF-8 BOM adds 3 bytes; allow small variance
|
||||
[int64]$remoteSize -ge ([int64]$hostSize - 6) -and [int64]$remoteSize -le ([int64]$hostSize + 6)
|
||||
}
|
||||
|
||||
# ── Build argument list for Setup-WinBuild2022.ps1 ───────────────────────
|
||||
$setupArgs = @{
|
||||
BuildPassword = $BuildPassword
|
||||
BuildUsername = $BuildUsername
|
||||
DotNetSdkVersion = $DotNetSdkVersion
|
||||
AdminPassword = $AdminPassword
|
||||
}
|
||||
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
|
||||
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
|
||||
|
||||
# ── Run Setup-WinBuild2022.ps1 inside the VM ──────────────────────────────
|
||||
# Setup may return exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) when Windows
|
||||
# Update applied patches that need a reboot. We loop: reboot guest, wait for
|
||||
# WinRM, reopen session, re-invoke Setup. Steps 1-5 are idempotent (~30 s);
|
||||
# Step 6 picks up where the previous run left off (no leftover updates →
|
||||
# exit 0). Hard cap on iterations to avoid an infinite loop.
|
||||
$maxWuIterations = 10
|
||||
$rebootDeadlineMin = 20
|
||||
|
||||
function Invoke-GuestSetup {
|
||||
param([Parameter(Mandatory)] $Session,
|
||||
[Parameter(Mandatory)] [string] $ScriptPath,
|
||||
[Parameter(Mandatory)] [hashtable] $ScriptArgs)
|
||||
Invoke-Command -Session $Session -ScriptBlock {
|
||||
param($scriptPath, $scriptArgs)
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
$exitCode = 0
|
||||
try {
|
||||
& $scriptPath @scriptArgs
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($null -eq $exitCode) { $exitCode = 0 }
|
||||
} catch {
|
||||
Write-Error $_
|
||||
$exitCode = 1
|
||||
}
|
||||
return $exitCode
|
||||
} -ArgumentList $ScriptPath, $ScriptArgs
|
||||
}
|
||||
|
||||
function Wait-GuestWinRMReady {
|
||||
param([Parameter(Mandatory)] [string] $IP,
|
||||
[Parameter(Mandatory)] $Cred,
|
||||
[Parameter(Mandatory)] $SessionOptions,
|
||||
[int] $TimeoutMin = 20)
|
||||
$deadline = (Get-Date).AddMinutes($TimeoutMin)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Seconds 10
|
||||
try {
|
||||
# Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe.
|
||||
$probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL `
|
||||
-SessionOption $SessionOptions -Credential $Cred `
|
||||
-Authentication Basic -ErrorAction Stop
|
||||
Remove-PSSession $probe -ErrorAction SilentlyContinue
|
||||
return $true
|
||||
} catch { }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] Running Setup-WinBuild2022.ps1 inside VM (this will take a while)..."
|
||||
|
||||
for ($iter = 1; $iter -le $maxWuIterations; $iter++) {
|
||||
Write-Host "[Prepare] Setup iteration $iter/$maxWuIterations"
|
||||
Write-Host "[Prepare] Output from guest:"
|
||||
Write-Host "------------------------------------------------------------"
|
||||
|
||||
$result = $null
|
||||
try {
|
||||
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
|
||||
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
|
||||
# VM rebooted mid-update (WU triggered OS restart before Setup could return 3010).
|
||||
# Treat as reboot-required: close dead session, fall through to the reboot-wait block.
|
||||
Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..."
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
$session = $null
|
||||
$result = 3010
|
||||
}
|
||||
|
||||
Write-Host "------------------------------------------------------------"
|
||||
Write-Host "[Prepare] Iteration $iter exit code: $result"
|
||||
|
||||
if ($result -eq 0) { break }
|
||||
|
||||
if ($result -ne 3010) {
|
||||
throw "Setup-WinBuild2022.ps1 exited with code $result"
|
||||
}
|
||||
|
||||
# exit 3010 (or transport error treated as 3010) → WU needs reboot.
|
||||
# Reboot guest if session still alive, wait for WinRM, reopen session, loop.
|
||||
if ($iter -eq $maxWuIterations) {
|
||||
throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)"
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..."
|
||||
if ($session) {
|
||||
try {
|
||||
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 30 # let WinRM tear down before polling
|
||||
Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..."
|
||||
if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential `
|
||||
-SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) {
|
||||
throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot"
|
||||
}
|
||||
Write-Host "[Prepare] Guest WinRM ready, reopening session..."
|
||||
$session = New-PSSession -ComputerName $VMIPAddress -Credential $credential `
|
||||
-SessionOption $sessionOptions -UseSSL -Port 5986 `
|
||||
-Authentication Basic -ErrorAction Stop
|
||||
}
|
||||
|
||||
# ── Post-setup remote validation ──────────────────────────────────────────
|
||||
Write-Host "[Prepare] Running post-setup validation against guest..."
|
||||
$guestState = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($BuildUser)
|
||||
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' +
|
||||
[System.Environment]::GetEnvironmentVariable('PATH','User')
|
||||
[PSCustomObject]@{
|
||||
WinRMRunning = (Get-Service WinRM -ErrorAction SilentlyContinue).Status -eq 'Running'
|
||||
UserExists = [bool](Get-LocalUser -Name $BuildUser -ErrorAction SilentlyContinue)
|
||||
UserIsAdmin = [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUser)
|
||||
UACDisabled = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA -eq 0
|
||||
FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true)
|
||||
DotNetVersion = (Get-Command dotnet -ErrorAction SilentlyContinue) | ForEach-Object { (& $_.Source --version 2>&1) }
|
||||
PythonExists = Test-Path 'C:\Python\python.exe' -PathType Leaf
|
||||
MSBuildExists = Test-Path $msbuildPath -PathType Leaf
|
||||
CIDirsPresent = (Test-Path 'C:\CI\build') -and (Test-Path 'C:\CI\output') -and (Test-Path 'C:\CI\scripts')
|
||||
}
|
||||
} -ArgumentList $BuildUsername
|
||||
|
||||
Assert-Step 'PostSetup' 'guest WinRM Running' { $guestState.WinRMRunning }
|
||||
Assert-Step 'PostSetup' "guest user '$BuildUsername' exists" { $guestState.UserExists }
|
||||
Assert-Step 'PostSetup' "guest user '$BuildUsername' is admin" { $guestState.UserIsAdmin }
|
||||
Assert-Step 'PostSetup' 'guest UAC disabled' { $guestState.UACDisabled }
|
||||
Assert-Step 'PostSetup' 'guest firewall disabled' { $guestState.FirewallOff }
|
||||
Assert-Step 'PostSetup' 'guest .NET SDK installed' { [bool]$guestState.DotNetVersion }
|
||||
Assert-Step 'PostSetup' 'guest Python installed' { $guestState.PythonExists }
|
||||
Assert-Step 'PostSetup' 'guest MSBuild present' { $guestState.MSBuildExists }
|
||||
Assert-Step 'PostSetup' 'guest C:\CI\{build,output,scripts} present' { $guestState.CIDirsPresent }
|
||||
|
||||
Write-Host "[Prepare] All post-setup validations passed (.NET $($guestState.DotNetVersion))." -ForegroundColor Green
|
||||
|
||||
# ── Store credentials in Windows Credential Manager (optional) ────────────
|
||||
if ($StoreCredential) {
|
||||
Write-Host "[Prepare] Storing CI credentials in Windows Credential Manager (target: '$CredentialTarget')..."
|
||||
if (-not (Get-Module -ListAvailable -Name CredentialManager)) {
|
||||
Write-Host "[Prepare] CredentialManager module not found — installing (CurrentUser scope)..."
|
||||
Install-Module -Name CredentialManager -Scope CurrentUser -Force -ErrorAction Stop
|
||||
}
|
||||
Import-Module CredentialManager -ErrorAction Stop
|
||||
New-StoredCredential `
|
||||
-Target $CredentialTarget `
|
||||
-UserName $BuildUsername `
|
||||
-Password $BuildPassword `
|
||||
-Persist LocalMachine `
|
||||
-ErrorAction Stop | Out-Null
|
||||
Write-Host "[Prepare] Credentials stored: target='$CredentialTarget' user='$BuildUsername'." -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] All validations passed." -ForegroundColor Green
|
||||
|
||||
# ── Shutdown + snapshot prompt ─────────────────────────────────────────────
|
||||
# vmrun.exe already located at script start ($vmrunExe)
|
||||
|
||||
# Auto-discover VMX path via 'vmrun list' while VM is still running (if not known)
|
||||
if ($VMXPath -eq '') {
|
||||
Write-Host "[Prepare] Discovering VMX path from running VMs..."
|
||||
$runningVMs = @(& $vmrunExe list 2>&1 |
|
||||
Where-Object { $_ -match '\.vmx$' } |
|
||||
ForEach-Object { $_.Trim() })
|
||||
|
||||
if ($runningVMs.Count -eq 0) {
|
||||
throw "[Prepare] No running VMs found via 'vmrun list'. Is the template VM still up?"
|
||||
}
|
||||
elseif ($runningVMs.Count -eq 1) {
|
||||
$VMXPath = $runningVMs[0]
|
||||
Write-Host "[Prepare] VMX auto-detected: $VMXPath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
# Multiple VMs — prefer one under \CI\Templates\
|
||||
$ciVMs = @($runningVMs | Where-Object { $_ -like '*\CI\Templates\*' })
|
||||
if ($ciVMs.Count -eq 1) {
|
||||
$VMXPath = $ciVMs[0]
|
||||
Write-Host "[Prepare] VMX auto-detected (CI template): $VMXPath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "[Prepare] Multiple VMs running — select one:"
|
||||
for ($i = 0; $i -lt $runningVMs.Count; $i++) {
|
||||
Write-Host " [$($i+1)] $($runningVMs[$i])"
|
||||
}
|
||||
$idx = [int](Read-Host "[Prepare] Enter number") - 1
|
||||
if ($idx -lt 0 -or $idx -ge $runningVMs.Count) { throw "[Prepare] Invalid selection." }
|
||||
$VMXPath = $runningVMs[$idx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found on disk: '$VMXPath'" }
|
||||
|
||||
# Prompt
|
||||
Write-Host "------------------------------------------------------------"
|
||||
Write-Host ""
|
||||
$choice = Read-Host "[Prepare] Shut down VM and create snapshot '$SnapshotName'? [Y/N]"
|
||||
if ($choice -match '^[Yy]') {
|
||||
|
||||
# Send graceful shutdown
|
||||
Write-Host "[Prepare] Sending graceful shutdown to VM..."
|
||||
try {
|
||||
Invoke-Command -Session $session -ScriptBlock { Stop-Computer -Force } -ErrorAction Stop
|
||||
Write-Host "[Prepare] Shutdown command sent." -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning "[Prepare] Shutdown command failed (VM may have already shut down): $_"
|
||||
}
|
||||
|
||||
# Close session — VM is shutting down, no further WinRM needed
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
|
||||
# Wait for VM to power off (poll vmrun list)
|
||||
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
|
||||
$shutdownDeadline = [datetime]::UtcNow.AddSeconds(120)
|
||||
$poweredOff = $false
|
||||
while ([datetime]::UtcNow -lt $shutdownDeadline) {
|
||||
$runningList = & $vmrunExe list 2>&1 | Out-String
|
||||
if ($runningList -notmatch [regex]::Escape($VMXPath)) { $poweredOff = $true; break }
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host " ... waiting for shutdown..."
|
||||
}
|
||||
if (-not $poweredOff) { throw "[Prepare] VM did not power off within 120 s. Check VMware Workstation." }
|
||||
|
||||
# Check for existing snapshot with same name
|
||||
$existingSnaps = & $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String
|
||||
$doCreate = $true
|
||||
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
|
||||
Write-Warning "[Prepare] Snapshot '$SnapshotName' already exists."
|
||||
$delChoice = Read-Host "[Prepare] Delete existing snapshot and recreate? [Y/N]"
|
||||
if ($delChoice -match '^[Yy]') {
|
||||
Write-Host "[Prepare] Deleting existing snapshot '$SnapshotName'..."
|
||||
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun deleteSnapshot exited $LASTEXITCODE." }
|
||||
Write-Host "[Prepare] Existing snapshot deleted." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "[Prepare] Keeping existing snapshot — skipping creation." -ForegroundColor Yellow
|
||||
$doCreate = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Create snapshot
|
||||
if ($doCreate) {
|
||||
Write-Host "[Prepare] Creating snapshot '$SnapshotName'..." -ForegroundColor Green
|
||||
& $vmrunExe -T ws snapshot $VMXPath $SnapshotName
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun snapshot exited $LASTEXITCODE." }
|
||||
Write-Host "[Prepare] Snapshot '$SnapshotName' created successfully." -ForegroundColor Green
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
if ($StoreCredential) {
|
||||
Write-Host " [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " MANUAL STEPS REQUIRED:"
|
||||
Write-Host ""
|
||||
Write-Host " 1. Shut down the VM: Start -> Shut down"
|
||||
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
|
||||
Write-Host ""
|
||||
Write-Host " 2. Take snapshot in VMware Workstation:"
|
||||
Write-Host " VM -> Snapshot -> Take Snapshot"
|
||||
Write-Host " Name it EXACTLY: $SnapshotName"
|
||||
Write-Host ""
|
||||
if ($StoreCredential) {
|
||||
Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " 3. Store CI credentials on this HOST (run in elevated PowerShell):"
|
||||
Write-Host " .\Prepare-WinBuild2022.ps1 -VMIPAddress $VMIPAddress -StoreCredential"
|
||||
Write-Host " (or manually:)"
|
||||
Write-Host " New-StoredCredential -Target '$CredentialTarget' ``"
|
||||
Write-Host " -UserName '$BuildUsername' -Password '<your-build-password>' ``"
|
||||
Write-Host " -Persist LocalMachine"
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
|
||||
# Restore host TrustedHosts to its original value
|
||||
if ($null -ne $prevTrustedHosts) {
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "[Prepare] Host TrustedHosts restored."
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
#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/5986 reachable
|
||||
2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) — restored on exit
|
||||
3. Validates host WinRM TrustedHosts applied correctly
|
||||
4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck — fails fast with actionable message
|
||||
(PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe)
|
||||
5. PSSession open — step 4 and 5 are now merged
|
||||
6. Ensures C:\CI exists on guest; validates creation
|
||||
7. Copies Setup-WinBuild2025.ps1 into the VM; validates file present + size match
|
||||
8. Runs Setup-WinBuild2025.ps1 inside the VM with the given parameters
|
||||
(Setup performs per-step validation internally — aborts on any failure)
|
||||
9. Handles the reboot-required case (exit 3010) and optionally re-runs
|
||||
10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command
|
||||
(WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs)
|
||||
11. Restores host TrustedHosts in finally block
|
||||
|
||||
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
|
||||
The snapshot should only be taken after this script exits 0.
|
||||
|
||||
PREREQUISITES (do these manually before running this script):
|
||||
a. VM NIC is set to VMnet8 (NAT) — the VM needs internet for downloads
|
||||
b. Windows Server is installed and booted
|
||||
c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run:
|
||||
winrm quickconfig -q
|
||||
Enable-PSRemoting -Force -SkipNetworkProfileCheck
|
||||
d. You know the IP address the VM got from DHCP on VMnet8 (NAT)
|
||||
(check via: ipconfig inside the VM, or VMware → VM → Settings →
|
||||
Network Adapter → Advanced → MAC address, then check DHCP leases)
|
||||
|
||||
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
|
||||
1. Shut down the VM
|
||||
2. Take snapshot: VM → Snapshot → Take Snapshot → name: BaseClean
|
||||
(VM stays on VMnet8 NAT — internet access is required for builds)
|
||||
3. Store credentials on host (use the same password chosen during provisioning):
|
||||
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
|
||||
-Password '<your-build-password>' -Persist LocalMachine
|
||||
|
||||
.PARAMETER VMXPath
|
||||
Full path to the template VM's .vmx file (e.g. F:\CI\Templates\WinBuild\WinBuild.vmx).
|
||||
When provided:
|
||||
- If the VM is powered off, the script starts it automatically via vmrun.
|
||||
- The guest IP is detected automatically via vmrun getGuestIPAddress (requires
|
||||
VMware Tools installed in the guest).
|
||||
- At the end, the snapshot is created automatically after provisioning.
|
||||
Either -VMXPath or -VMIPAddress (or both) must be supplied.
|
||||
|
||||
.PARAMETER VMIPAddress
|
||||
IP address of the template VM on VMnet8 NAT.
|
||||
Optional when -VMXPath is provided (IP is auto-detected via VMware Tools).
|
||||
Required when -VMXPath is omitted.
|
||||
Example: 192.168.79.130
|
||||
|
||||
.PARAMETER AdminUsername
|
||||
Administrator username inside the VM. Default: Administrator
|
||||
|
||||
.PARAMETER AdminPassword
|
||||
Administrator password inside the VM. If not supplied, prompts interactively.
|
||||
|
||||
.PARAMETER BuildPassword
|
||||
Password to set for the ci_build user inside the VM.
|
||||
Must not be empty — script prompts interactively if not supplied.
|
||||
|
||||
.PARAMETER BuildUsername
|
||||
Local username for the CI build account inside the VM. Default: ci_build.
|
||||
Passed through to Setup-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`:5986 (timeout ${winrmTimeoutSec}s)..."
|
||||
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
|
||||
$winrmReady = $false
|
||||
while ([datetime]::UtcNow -lt $winrmDeadline) {
|
||||
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 `
|
||||
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
|
||||
$winrmReady = $true; break
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host " ... waiting for WinRM..."
|
||||
}
|
||||
Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady }
|
||||
|
||||
# Prompt for BuildPassword if not supplied — never use a hardcoded default.
|
||||
if ($BuildPassword -eq '') {
|
||||
Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:"
|
||||
$secPwd = Read-Host -Prompt " BuildPassword" -AsSecureString
|
||||
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
|
||||
$BuildPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
|
||||
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
|
||||
# ── Build PSCredential ────────────────────────────────────────────────────────
|
||||
if ($AdminPassword -eq '') {
|
||||
Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):"
|
||||
$credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)"
|
||||
}
|
||||
else {
|
||||
$secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force
|
||||
$credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd)
|
||||
}
|
||||
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
# ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ──────
|
||||
# HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts
|
||||
# needs appending so PS accepts the non-domain VM. Restored in the finally block.
|
||||
$prevTrustedHosts = $null
|
||||
try {
|
||||
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
|
||||
# Add VM IP to TrustedHosts if not already present
|
||||
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
|
||||
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
|
||||
}
|
||||
Write-Host "[Prepare] Host TrustedHosts configured."
|
||||
Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" {
|
||||
$th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
||||
$th -eq '*' -or $th -like "*$VMIPAddress*"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)."
|
||||
Write-Warning "Run once in an elevated PowerShell on the HOST:"
|
||||
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
|
||||
Write-Warning "Then re-run this script."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
# ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ──
|
||||
# New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert.
|
||||
Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..."
|
||||
$session = try {
|
||||
New-PSSession `
|
||||
-ComputerName $VMIPAddress `
|
||||
-Credential $credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-UseSSL `
|
||||
-Port 5986 `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
} catch {
|
||||
Write-Error @"
|
||||
[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
|
||||
|
||||
Ensure:
|
||||
- The VM is running and on VMnet8 (NAT) with internet access
|
||||
- WinRM HTTPS is enabled inside the VM — Deploy-WinBuild2025.ps1 post-install.ps1
|
||||
must have run successfully (creates self-signed cert + HTTPS listener on 5986)
|
||||
- Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest)
|
||||
- The IP is correct (check ipconfig inside the VM)
|
||||
|
||||
Error: $_
|
||||
"@
|
||||
exit 1
|
||||
}
|
||||
|
||||
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
|
||||
AdminPassword = $AdminPassword
|
||||
}
|
||||
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 {
|
||||
# Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe.
|
||||
$probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL `
|
||||
-SessionOption $SessionOptions -Credential $Cred `
|
||||
-Authentication Basic -ErrorAction Stop
|
||||
Remove-PSSession $probe -ErrorAction SilentlyContinue
|
||||
return $true
|
||||
} catch { }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] Running Setup-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 = $null
|
||||
try {
|
||||
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
|
||||
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
|
||||
# VM rebooted mid-update (WU triggered OS restart before Setup could return 3010).
|
||||
# Treat as reboot-required: close dead session, fall through to the reboot-wait block.
|
||||
Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..."
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
$session = $null
|
||||
$result = 3010
|
||||
}
|
||||
|
||||
Write-Host "------------------------------------------------------------"
|
||||
Write-Host "[Prepare] Iteration $iter exit code: $result"
|
||||
|
||||
if ($result -eq 0) { break }
|
||||
|
||||
if ($result -ne 3010) {
|
||||
throw "Setup-WinBuild2025.ps1 exited with code $result"
|
||||
}
|
||||
|
||||
# exit 3010 (or transport error treated as 3010) → WU needs reboot.
|
||||
# Reboot guest if session still alive, wait for WinRM, reopen session, loop.
|
||||
if ($iter -eq $maxWuIterations) {
|
||||
throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)"
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..."
|
||||
if ($session) {
|
||||
try {
|
||||
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 30 # let WinRM tear down before polling
|
||||
Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..."
|
||||
if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential `
|
||||
-SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) {
|
||||
throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot"
|
||||
}
|
||||
Write-Host "[Prepare] Guest WinRM ready, reopening session..."
|
||||
$session = New-PSSession -ComputerName $VMIPAddress -Credential $credential `
|
||||
-SessionOption $sessionOptions -UseSSL -Port 5986 `
|
||||
-Authentication Basic -ErrorAction Stop
|
||||
}
|
||||
|
||||
# ── Post-setup remote validation ──────────────────────────────────────────
|
||||
Write-Host "[Prepare] Running post-setup validation against guest..."
|
||||
$guestState = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($BuildUser)
|
||||
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' +
|
||||
[System.Environment]::GetEnvironmentVariable('PATH','User')
|
||||
[PSCustomObject]@{
|
||||
WinRMRunning = (Get-Service WinRM -ErrorAction SilentlyContinue).Status -eq 'Running'
|
||||
UserExists = [bool](Get-LocalUser -Name $BuildUser -ErrorAction SilentlyContinue)
|
||||
UserIsAdmin = [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUser)
|
||||
UACDisabled = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA -eq 0
|
||||
FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true)
|
||||
DotNetVersion = (Get-Command dotnet -ErrorAction SilentlyContinue) | ForEach-Object { (& $_.Source --version 2>&1) }
|
||||
PythonExists = Test-Path 'C:\Python\python.exe' -PathType Leaf
|
||||
MSBuildExists = Test-Path $msbuildPath -PathType Leaf
|
||||
CIDirsPresent = (Test-Path 'C:\CI\build') -and (Test-Path 'C:\CI\output') -and (Test-Path 'C:\CI\scripts')
|
||||
}
|
||||
} -ArgumentList $BuildUsername
|
||||
|
||||
Assert-Step 'PostSetup' 'guest WinRM Running' { $guestState.WinRMRunning }
|
||||
Assert-Step 'PostSetup' "guest user '$BuildUsername' exists" { $guestState.UserExists }
|
||||
Assert-Step 'PostSetup' "guest user '$BuildUsername' is admin" { $guestState.UserIsAdmin }
|
||||
Assert-Step 'PostSetup' 'guest UAC disabled' { $guestState.UACDisabled }
|
||||
Assert-Step 'PostSetup' 'guest firewall disabled' { $guestState.FirewallOff }
|
||||
Assert-Step 'PostSetup' 'guest .NET SDK installed' { [bool]$guestState.DotNetVersion }
|
||||
Assert-Step 'PostSetup' 'guest Python installed' { $guestState.PythonExists }
|
||||
Assert-Step 'PostSetup' 'guest MSBuild present' { $guestState.MSBuildExists }
|
||||
Assert-Step 'PostSetup' 'guest C:\CI\{build,output,scripts} present' { $guestState.CIDirsPresent }
|
||||
|
||||
Write-Host "[Prepare] All post-setup validations passed (.NET $($guestState.DotNetVersion))." -ForegroundColor Green
|
||||
|
||||
# ── Store credentials in Windows Credential Manager (optional) ────────────
|
||||
if ($StoreCredential) {
|
||||
Write-Host "[Prepare] Storing CI credentials in Windows Credential Manager (target: '$CredentialTarget')..."
|
||||
if (-not (Get-Module -ListAvailable -Name CredentialManager)) {
|
||||
Write-Host "[Prepare] CredentialManager module not found — installing (CurrentUser scope)..."
|
||||
Install-Module -Name CredentialManager -Scope CurrentUser -Force -ErrorAction Stop
|
||||
}
|
||||
Import-Module CredentialManager -ErrorAction Stop
|
||||
New-StoredCredential `
|
||||
-Target $CredentialTarget `
|
||||
-UserName $BuildUsername `
|
||||
-Password $BuildPassword `
|
||||
-Persist LocalMachine `
|
||||
-ErrorAction Stop | Out-Null
|
||||
Write-Host "[Prepare] Credentials stored: target='$CredentialTarget' user='$BuildUsername'." -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "[Prepare] All validations passed." -ForegroundColor Green
|
||||
|
||||
# ── Shutdown + snapshot prompt ─────────────────────────────────────────────
|
||||
# vmrun.exe already located at script start ($vmrunExe)
|
||||
|
||||
# Auto-discover VMX path via 'vmrun list' while VM is still running (if not known)
|
||||
if ($VMXPath -eq '') {
|
||||
Write-Host "[Prepare] Discovering VMX path from running VMs..."
|
||||
$runningVMs = @(& $vmrunExe list 2>&1 |
|
||||
Where-Object { $_ -match '\.vmx$' } |
|
||||
ForEach-Object { $_.Trim() })
|
||||
|
||||
if ($runningVMs.Count -eq 0) {
|
||||
throw "[Prepare] No running VMs found via 'vmrun list'. Is the template VM still up?"
|
||||
}
|
||||
elseif ($runningVMs.Count -eq 1) {
|
||||
$VMXPath = $runningVMs[0]
|
||||
Write-Host "[Prepare] VMX auto-detected: $VMXPath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
# Multiple VMs — prefer one under \CI\Templates\
|
||||
$ciVMs = @($runningVMs | Where-Object { $_ -like '*\CI\Templates\*' })
|
||||
if ($ciVMs.Count -eq 1) {
|
||||
$VMXPath = $ciVMs[0]
|
||||
Write-Host "[Prepare] VMX auto-detected (CI template): $VMXPath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "[Prepare] Multiple VMs running — select one:"
|
||||
for ($i = 0; $i -lt $runningVMs.Count; $i++) {
|
||||
Write-Host " [$($i+1)] $($runningVMs[$i])"
|
||||
}
|
||||
$idx = [int](Read-Host "[Prepare] Enter number") - 1
|
||||
if ($idx -lt 0 -or $idx -ge $runningVMs.Count) { throw "[Prepare] Invalid selection." }
|
||||
$VMXPath = $runningVMs[$idx]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found on disk: '$VMXPath'" }
|
||||
|
||||
# Prompt
|
||||
Write-Host "------------------------------------------------------------"
|
||||
Write-Host ""
|
||||
$choice = Read-Host "[Prepare] Shut down VM and create snapshot '$SnapshotName'? [Y/N]"
|
||||
if ($choice -match '^[Yy]') {
|
||||
|
||||
# Send graceful shutdown
|
||||
Write-Host "[Prepare] Sending graceful shutdown to VM..."
|
||||
try {
|
||||
Invoke-Command -Session $session -ScriptBlock { Stop-Computer -Force } -ErrorAction Stop
|
||||
Write-Host "[Prepare] Shutdown command sent." -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Warning "[Prepare] Shutdown command failed (VM may have already shut down): $_"
|
||||
}
|
||||
|
||||
# Close session — VM is shutting down, no further WinRM needed
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
|
||||
# Wait for VM to power off (poll vmrun list)
|
||||
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
|
||||
$shutdownDeadline = [datetime]::UtcNow.AddSeconds(120)
|
||||
$poweredOff = $false
|
||||
while ([datetime]::UtcNow -lt $shutdownDeadline) {
|
||||
$runningList = & $vmrunExe list 2>&1 | Out-String
|
||||
if ($runningList -notmatch [regex]::Escape($VMXPath)) { $poweredOff = $true; break }
|
||||
Start-Sleep -Seconds 5
|
||||
Write-Host " ... waiting for shutdown..."
|
||||
}
|
||||
if (-not $poweredOff) { throw "[Prepare] VM did not power off within 120 s. Check VMware Workstation." }
|
||||
|
||||
# Check for existing snapshot with same name
|
||||
$existingSnaps = & $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String
|
||||
$doCreate = $true
|
||||
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
|
||||
Write-Warning "[Prepare] Snapshot '$SnapshotName' already exists."
|
||||
$delChoice = Read-Host "[Prepare] Delete existing snapshot and recreate? [Y/N]"
|
||||
if ($delChoice -match '^[Yy]') {
|
||||
Write-Host "[Prepare] Deleting existing snapshot '$SnapshotName'..."
|
||||
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun deleteSnapshot exited $LASTEXITCODE." }
|
||||
Write-Host "[Prepare] Existing snapshot deleted." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "[Prepare] Keeping existing snapshot — skipping creation." -ForegroundColor Yellow
|
||||
$doCreate = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Create snapshot
|
||||
if ($doCreate) {
|
||||
Write-Host "[Prepare] Creating snapshot '$SnapshotName'..." -ForegroundColor Green
|
||||
& $vmrunExe -T ws snapshot $VMXPath $SnapshotName
|
||||
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun snapshot exited $LASTEXITCODE." }
|
||||
Write-Host "[Prepare] Snapshot '$SnapshotName' created successfully." -ForegroundColor Green
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
if ($StoreCredential) {
|
||||
Write-Host " [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
|
||||
}
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " MANUAL STEPS REQUIRED:"
|
||||
Write-Host ""
|
||||
Write-Host " 1. Shut down the VM: Start -> Shut down"
|
||||
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
|
||||
Write-Host ""
|
||||
Write-Host " 2. Take snapshot in VMware Workstation:"
|
||||
Write-Host " VM -> Snapshot -> Take Snapshot"
|
||||
Write-Host " Name it EXACTLY: $SnapshotName"
|
||||
Write-Host ""
|
||||
if ($StoreCredential) {
|
||||
Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host " 3. Store CI credentials on this HOST (run in elevated PowerShell):"
|
||||
Write-Host " .\Prepare-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 TrustedHosts to its original value
|
||||
if ($null -ne $prevTrustedHosts) {
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host "[Prepare] Host TrustedHosts restored."
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup-LinuxBuild2404.sh
|
||||
# Guest-side CI toolchain setup for Ubuntu 24.04 LTS build template.
|
||||
# Run as ci_build (passwordless sudo) via: sudo /tmp/Setup-LinuxBuild2404.sh
|
||||
# Called by: template/Prepare-LinuxBuild2404.ps1 (host-side)
|
||||
#
|
||||
# Usage:
|
||||
# sudo /tmp/Setup-LinuxBuild2404.sh [--skip-update] [--dotnet] [--mingw]
|
||||
#
|
||||
# Options:
|
||||
# --skip-update Skip apt update + upgrade (faster re-runs)
|
||||
# --dotnet Install .NET SDK 10.0 (optional, adds ~500 MB)
|
||||
# --no-mingw Skip mingw-w64 cross-compiler (installed by default)
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 All steps passed, system ready for snapshot
|
||||
# 1 One or more steps failed (output shows which step and why)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- Option parsing ---
|
||||
SKIP_UPDATE=0
|
||||
INSTALL_DOTNET=0
|
||||
INSTALL_MINGW=1
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--skip-update) SKIP_UPDATE=1 ;;
|
||||
--dotnet) INSTALL_DOTNET=1 ;;
|
||||
--no-mingw) INSTALL_MINGW=0 ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# --- Assert helper ---
|
||||
STEP=0
|
||||
|
||||
assert_step() {
|
||||
local desc="$1"
|
||||
shift
|
||||
STEP=$((STEP + 1))
|
||||
if "$@"; then
|
||||
echo " [OK] [Step ${STEP}] ${desc}"
|
||||
else
|
||||
echo " [FAIL] [Step ${STEP}] ${desc}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Step 1: apt update + upgrade ---
|
||||
echo ""
|
||||
echo "=== Step 1: apt update + upgrade ==="
|
||||
if [ "${SKIP_UPDATE:-0}" = "0" ]; then
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
|
||||
assert_step "apt update + upgrade completed" true
|
||||
else
|
||||
echo " [SKIP] --skip-update flag set"
|
||||
fi
|
||||
|
||||
# --- Step 2: build essentials ---
|
||||
echo ""
|
||||
echo "=== Step 2: build essentials ==="
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
build-essential gcc g++ clang \
|
||||
make cmake ninja-build \
|
||||
pkg-config autoconf automake libtool
|
||||
assert_step "gcc available" command -v gcc
|
||||
assert_step "g++ available" command -v g++
|
||||
assert_step "clang available" command -v clang
|
||||
assert_step "cmake available" command -v cmake
|
||||
assert_step "make available" command -v make
|
||||
|
||||
# --- Step 3: Python ---
|
||||
echo ""
|
||||
echo "=== Step 3: Python ==="
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3 python3-pip python3-venv
|
||||
sudo ln -sf /usr/bin/python3 /usr/local/bin/python
|
||||
assert_step "python3 available" command -v python3
|
||||
assert_step "pip3 available" command -v pip3
|
||||
|
||||
# --- Step 4: Tier-1 toolchain ---
|
||||
echo ""
|
||||
echo "=== Step 4: Tier-1 toolchain (git, 7-zip, curl, wget) ==="
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git p7zip-full curl wget ca-certificates
|
||||
assert_step "git available" command -v git
|
||||
assert_step "7z available" command -v 7z
|
||||
assert_step "curl available" command -v curl
|
||||
|
||||
# --- Step 4b: .NET SDK (optional) ---
|
||||
echo ""
|
||||
echo "=== Step 4b: .NET SDK (optional) ==="
|
||||
if [ "${INSTALL_DOTNET:-0}" = "1" ]; then
|
||||
curl -fsSL https://dotnet.microsoft.com/download/dotnet/scripts/v1/dotnet-install.sh \
|
||||
| sudo bash -s -- --channel 10.0 --install-dir /usr/local/dotnet
|
||||
sudo ln -sf /usr/local/dotnet/dotnet /usr/local/bin/dotnet
|
||||
assert_step "dotnet available" command -v dotnet
|
||||
else
|
||||
echo " [SKIP] --dotnet not specified"
|
||||
fi
|
||||
|
||||
# --- Step 4c: mingw-w64 cross-compiler (default ON: required by ns7zip Linux build) ---
|
||||
echo ""
|
||||
echo "=== Step 4c: mingw-w64 cross-compiler ==="
|
||||
if [ "$INSTALL_MINGW" = "1" ]; then
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
binutils-mingw-w64-i686 binutils-mingw-w64-x86-64 \
|
||||
gcc-mingw-w64-i686 gcc-mingw-w64-x86-64 \
|
||||
g++-mingw-w64-i686 g++-mingw-w64-x86-64
|
||||
assert_step "i686-w64-mingw32-gcc available" command -v i686-w64-mingw32-gcc
|
||||
assert_step "x86_64-w64-mingw32-gcc available" command -v x86_64-w64-mingw32-gcc
|
||||
assert_step "i686-w64-mingw32-g++ available" command -v i686-w64-mingw32-g++
|
||||
assert_step "x86_64-w64-mingw32-g++ available" command -v x86_64-w64-mingw32-g++
|
||||
else
|
||||
echo " [SKIP] --no-mingw passed, skipping mingw-w64"
|
||||
fi
|
||||
|
||||
# --- Step 5: CI directories + sudo group ---
|
||||
echo ""
|
||||
echo "=== Step 5: CI directories ==="
|
||||
sudo mkdir -p /opt/ci/{build,output,scripts,cache}
|
||||
sudo chown -R ci_build:ci_build /opt/ci
|
||||
# Ensure ci_build is in the sudo group (belt-and-suspenders alongside sudoers.d entry)
|
||||
sudo usermod -aG sudo ci_build
|
||||
assert_step "/opt/ci/build exists" test -d /opt/ci/build
|
||||
assert_step "/opt/ci/output exists" test -d /opt/ci/output
|
||||
assert_step "/opt/ci/scripts exists" test -d /opt/ci/scripts
|
||||
assert_step "/opt/ci/cache exists" test -d /opt/ci/cache
|
||||
assert_step "ci_build owns /opt/ci" test "$(stat -c '%U' /opt/ci)" = "ci_build"
|
||||
|
||||
# --- Step 6: SSH hardening ---
|
||||
echo ""
|
||||
echo "=== Step 6: SSH hardening ==="
|
||||
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
||||
sudo sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
|
||||
sudo systemctl restart ssh
|
||||
assert_step "PasswordAuthentication no in sshd_config" grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config
|
||||
assert_step "sshd service active" systemctl is-active ssh
|
||||
|
||||
# --- Step 7: disable swap ---
|
||||
echo ""
|
||||
echo "=== Step 7: disable swap ==="
|
||||
sudo swapoff -a
|
||||
sudo sed -i '/ swap / s/^/#/' /etc/fstab
|
||||
assert_step "no active swap" bash -c '[ -z "$(swapon --show)" ]'
|
||||
|
||||
# --- Step 7a: MAC-agnostic netplan for linked clones ---
|
||||
# Cloud-init generates /etc/netplan/50-cloud-init.yaml with the template's MAC
|
||||
# pinned via `match: macaddress: ...`. When VMware creates a linked clone with
|
||||
# a new MAC, that match fails and the clone has no network. We add a second
|
||||
# netplan file that matches any en* interface by name, so any clone (any MAC)
|
||||
# gets DHCP. The two files are merged; if 50-cloud-init also matches, fine —
|
||||
# we just guarantee DHCP works regardless.
|
||||
echo ""
|
||||
echo "=== Step 7a: MAC-agnostic netplan for clones ==="
|
||||
sudo tee /etc/netplan/99-ci-dhcp-allnics.yaml > /dev/null << 'EONETPLAN'
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
ci-all-en:
|
||||
match:
|
||||
name: "en*"
|
||||
dhcp4: true
|
||||
dhcp6: false
|
||||
EONETPLAN
|
||||
sudo chmod 600 /etc/netplan/99-ci-dhcp-allnics.yaml
|
||||
sudo chown root:root /etc/netplan/99-ci-dhcp-allnics.yaml
|
||||
sudo netplan generate
|
||||
assert_step "99-ci-dhcp-allnics.yaml exists" test -f /etc/netplan/99-ci-dhcp-allnics.yaml
|
||||
|
||||
# --- Step 7b: CI IP reporter via VMware guestinfo ---
|
||||
# The host reads the guest IP via `vmrun readVariable guestVar ci-ip`.
|
||||
# IMPORTANT: vmware-rpctool sets 'guestinfo.ci-ip' but vmrun reads it as just
|
||||
# 'ci-ip' under the guestVar namespace (the 'guestinfo.' prefix is implicit).
|
||||
# This is the official VMware VMCI channel — works even when TCP/IP is not yet
|
||||
# fully initialised on the host. Used as primary; getGuestIPAddress is fallback.
|
||||
echo ""
|
||||
echo "=== Step 7b: CI IP reporter ==="
|
||||
sudo tee /usr/local/bin/ci-report-ip.sh > /dev/null << 'EOSCRIPT'
|
||||
#!/bin/bash
|
||||
# Writes the VM's primary IPv4 to VMware guestinfo.ci-ip on every boot.
|
||||
# Called by ci-report-ip.service.
|
||||
# Retries for up to 60s to tolerate open-vm-tools re-init after UUID change
|
||||
# (linked clones get a new UUID — vmware-rpctool may fail briefly on first boot).
|
||||
for i in $(seq 1 30); do
|
||||
IP=$(ip -4 route get 1.0.0.0 2>/dev/null \
|
||||
| awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' \
|
||||
| head -1)
|
||||
if [ -n "$IP" ]; then
|
||||
if /usr/bin/vmware-rpctool "info-set guestinfo.ci-ip $IP" 2>/dev/null; then
|
||||
echo "ci-report-ip: set guestinfo.ci-ip=$IP (attempt $i)" | systemd-cat -t ci-report-ip
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "ci-report-ip: failed to set guestinfo.ci-ip after 30 attempts" | systemd-cat -t ci-report-ip
|
||||
exit 1
|
||||
EOSCRIPT
|
||||
sudo chmod +x /usr/local/bin/ci-report-ip.sh
|
||||
|
||||
sudo tee /etc/systemd/system/ci-report-ip.service > /dev/null << 'EOSERVICE'
|
||||
[Unit]
|
||||
Description=Report CI VM IP to VMware host via guestinfo
|
||||
After=open-vm-tools.service network.target
|
||||
Wants=open-vm-tools.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/ci-report-ip.sh
|
||||
RemainAfterExit=yes
|
||||
TimeoutStartSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOSERVICE
|
||||
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ci-report-ip.service
|
||||
assert_step "ci-report-ip.service enabled" systemctl is-enabled ci-report-ip.service
|
||||
|
||||
# --- Step 8: disable apt auto-update ---
|
||||
echo ""
|
||||
echo "=== Step 8: disable apt auto-update ==="
|
||||
sudo systemctl mask apt-daily.service apt-daily-upgrade.service \
|
||||
apt-daily.timer apt-daily-upgrade.timer 2>/dev/null || true
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y unattended-upgrades 2>/dev/null || true
|
||||
assert_step "apt-daily.timer masked or inactive" bash -c \
|
||||
'systemctl is-enabled apt-daily.timer 2>/dev/null | grep -qE "masked|disabled" || true'
|
||||
|
||||
# --- Step 9: cleanup pre-snapshot ---
|
||||
echo ""
|
||||
echo "=== Step 9: cleanup pre-snapshot ==="
|
||||
sudo apt-get clean
|
||||
sudo rm -rf /var/lib/apt/lists/*
|
||||
sudo journalctl --vacuum-time=1d 2>/dev/null || true
|
||||
sudo rm -rf /tmp/* /var/tmp/* 2>/dev/null || true
|
||||
history -c 2>/dev/null || true
|
||||
cat /dev/null > ~/.bash_history 2>/dev/null || true
|
||||
assert_step "apt cache cleaned" bash -c '[ -z "$(ls /var/cache/apt/archives/*.deb 2>/dev/null)" ]'
|
||||
|
||||
# --- Final Gate: pre-snapshot validation ---
|
||||
echo ""
|
||||
echo "=== Final Gate: pre-snapshot validation ==="
|
||||
|
||||
for tool in gcc g++ clang cmake make git 7z curl python3 pip3; do
|
||||
assert_step "${tool} in PATH" command -v "${tool}"
|
||||
done
|
||||
|
||||
for d in /opt/ci/build /opt/ci/output /opt/ci/scripts /opt/ci/cache; do
|
||||
assert_step "${d} exists" test -d "${d}"
|
||||
done
|
||||
assert_step "ci_build owns /opt/ci" test "$(stat -c '%U' /opt/ci)" = "ci_build"
|
||||
|
||||
assert_step "no active swap" bash -c '[ -z "$(swapon --show)" ]'
|
||||
|
||||
assert_step "sshd active" systemctl is-active ssh
|
||||
assert_step "PasswordAuthentication no" grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config
|
||||
|
||||
assert_step "root fs >= 38 GB" bash -c \
|
||||
'df -BG / | awk '\''NR==2{gsub("G","",$2); if ($2+0 < 38) {print "FAIL: root fs too small ("$2"G)"; exit 1}}'\'''
|
||||
|
||||
assert_step "/tmp writable" bash -c 'touch /tmp/.ci_tmpcheck && rm /tmp/.ci_tmpcheck'
|
||||
assert_step "ci-report-ip.service enabled" systemctl is-enabled ci-report-ip.service
|
||||
|
||||
echo ""
|
||||
echo "=== Setup-LinuxBuild2404.sh complete ==="
|
||||
echo " All steps passed. VM is ready for BaseClean-Linux snapshot."
|
||||
echo " Run Prepare-LinuxBuild2404.ps1 --Step7Shutdown + snapshot from host."
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates that Deploy-WinBuild2025.ps1 has correctly configured a guest VM.
|
||||
|
||||
.DESCRIPTION
|
||||
Connects to the VM via WinRM (HTTP/Basic) and runs all checks that
|
||||
Setup-WinBuild2025.ps1 Steps 1/2/3/4b/5b/5c assert as validation-only.
|
||||
Use this after Deploy completes and before running Prepare/Setup.
|
||||
|
||||
.PARAMETER VMIPAddress
|
||||
IP address of the guest VM on VMnet8 (e.g. 192.168.79.129).
|
||||
|
||||
.PARAMETER AdminUsername
|
||||
Admin account inside the VM (default: Administrator).
|
||||
|
||||
.PARAMETER AdminPassword
|
||||
Password for the admin account. Prompted if omitted.
|
||||
|
||||
.EXAMPLE
|
||||
.\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $VMIPAddress,
|
||||
[string] $AdminUsername = 'Administrator',
|
||||
[string] $AdminPassword
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $AdminPassword) {
|
||||
$secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString
|
||||
$AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
||||
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
|
||||
}
|
||||
|
||||
$cred = New-Object System.Management.Automation.PSCredential(
|
||||
$AdminUsername,
|
||||
(ConvertTo-SecureString $AdminPassword -AsPlainText -Force)
|
||||
)
|
||||
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
||||
|
||||
try {
|
||||
$cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
||||
if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) {
|
||||
$newHosts = if ($cur) { "$cur,$VMIPAddress" } else { $VMIPAddress }
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newHosts -Force
|
||||
}
|
||||
|
||||
Write-Host "`nValidating Deploy state on $VMIPAddress..." -ForegroundColor Cyan
|
||||
|
||||
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
|
||||
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
|
||||
|
||||
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
function Chk {
|
||||
param([string]$Name, [scriptblock]$Test)
|
||||
try { $ok = [bool](& $Test); $err = '' }
|
||||
catch { $ok = $false; $err = $_.Exception.Message }
|
||||
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
|
||||
}
|
||||
|
||||
# ── Firewall ─────────────────────────────────────────────────────────
|
||||
foreach ($p in Get-NetFirewallProfile) {
|
||||
$n = $p.Name; $e = $p.Enabled
|
||||
Chk "Firewall $n disabled" { $e -eq $false }
|
||||
}
|
||||
|
||||
# ── Defender ─────────────────────────────────────────────────────────
|
||||
Chk 'Defender GPO DisableAntiSpyware=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' `
|
||||
-Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1
|
||||
}
|
||||
|
||||
# ── WinRM ─────────────────────────────────────────────────────────────
|
||||
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
|
||||
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
|
||||
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' {
|
||||
(Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false'
|
||||
}
|
||||
Chk 'WinRM Auth/Basic=true' {
|
||||
(Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true'
|
||||
}
|
||||
Chk 'WinRM MaxMemoryPerShellMB >= 2048' {
|
||||
[int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048
|
||||
}
|
||||
|
||||
# ── UAC ───────────────────────────────────────────────────────────────
|
||||
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
Chk 'UAC EnableLUA=0' {
|
||||
(Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0
|
||||
}
|
||||
Chk 'UAC LocalAccountTokenFilterPolicy=1' {
|
||||
(Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1
|
||||
}
|
||||
|
||||
# ── Explorer ──────────────────────────────────────────────────────────
|
||||
Chk 'Explorer LaunchTo=1 (HKCU)' {
|
||||
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' `
|
||||
-Name LaunchTo -EA Stop).LaunchTo -eq 1
|
||||
}
|
||||
|
||||
# ── UX hardening ─────────────────────────────────────────────────────
|
||||
Chk 'NoLockScreen=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' `
|
||||
-Name NoLockScreen -EA Stop).NoLockScreen -eq 1
|
||||
}
|
||||
Chk 'Server Manager policy DoNotOpenAtLogon=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' `
|
||||
-Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1
|
||||
}
|
||||
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' `
|
||||
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
|
||||
}
|
||||
Chk 'Server Manager task Disabled' {
|
||||
$t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' `
|
||||
-TaskName 'ServerManager' -EA SilentlyContinue
|
||||
(-not $t) -or ($t.State -eq 'Disabled')
|
||||
}
|
||||
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' {
|
||||
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' `
|
||||
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
|
||||
}
|
||||
Chk 'DisableCAD=1 (Policies\System)' {
|
||||
(Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1
|
||||
}
|
||||
Chk 'OOBE DisablePrivacyExperience=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' `
|
||||
-Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1
|
||||
}
|
||||
Chk 'DataCollection AllowTelemetry=0' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' `
|
||||
-Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0
|
||||
}
|
||||
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' {
|
||||
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop
|
||||
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
|
||||
}
|
||||
|
||||
# ── Windows Update GPO + services ─────────────────────────────────────
|
||||
Chk 'WU GPO NoAutoUpdate=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `
|
||||
-Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1
|
||||
}
|
||||
Chk 'WU GPO DisableWindowsUpdateAccess=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
|
||||
-Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1
|
||||
}
|
||||
Chk 'wuauserv StartType=Manual' { (Get-Service wuauserv -EA Stop).StartType -eq 'Manual' }
|
||||
Chk 'UsoSvc StartType=Manual' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Manual' }
|
||||
|
||||
return $results.ToArray()
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
$failed = 0
|
||||
foreach ($r in $checks) {
|
||||
if ($r.Pass) {
|
||||
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
|
||||
} else {
|
||||
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
|
||||
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ($failed -eq 0) {
|
||||
Write-Host "All $($checks.Count) Deploy checks passed." -ForegroundColor Green
|
||||
exit 0
|
||||
} else {
|
||||
Write-Host "$failed / $($checks.Count) Deploy checks FAILED." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
} finally {
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validates full post-Setup state of a template VM (Deploy + Setup).
|
||||
|
||||
.DESCRIPTION
|
||||
Connects to the VM via WinRM (HTTP/Basic) and runs all checks for the
|
||||
final BaseClean snapshot state: Deploy OS hardening + Setup CI toolchain.
|
||||
Run this after Prepare-WinBuild2025.ps1 completes (exit 0) to confirm
|
||||
the VM is ready for snapshot and production use.
|
||||
|
||||
.PARAMETER VMIPAddress
|
||||
IP address of the guest VM on VMnet8 (e.g. 192.168.79.129).
|
||||
|
||||
.PARAMETER AdminUsername
|
||||
Admin account inside the VM (default: Administrator).
|
||||
|
||||
.PARAMETER AdminPassword
|
||||
Password for the admin account. Prompted if omitted.
|
||||
|
||||
.PARAMETER BuildUsername
|
||||
CI build account created by Setup (default: ci_build).
|
||||
|
||||
.PARAMETER DotNetChannel
|
||||
Expected .NET SDK channel prefix (default: 10.0).
|
||||
|
||||
.PARAMETER PythonVersion
|
||||
Expected exact Python version string (default: 3.13.3).
|
||||
|
||||
.PARAMETER Remediate
|
||||
If set, automatically applies fixes for known remediable failures
|
||||
(AutoAdminLogon, wuauserv/UsoSvc StartType=Disabled) then re-runs all checks.
|
||||
|
||||
.EXAMPLE
|
||||
.\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129
|
||||
|
||||
.EXAMPLE
|
||||
.\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129 -Remediate
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $VMIPAddress,
|
||||
[string] $AdminUsername = 'Administrator',
|
||||
[string] $AdminPassword,
|
||||
[string] $BuildUsername = 'ci_build',
|
||||
[string] $DotNetChannel = '10.0',
|
||||
[string] $PythonVersion = '3.13.3',
|
||||
[switch] $Remediate
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $AdminPassword) {
|
||||
$secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString
|
||||
$AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
||||
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
|
||||
}
|
||||
|
||||
$cred = New-Object System.Management.Automation.PSCredential(
|
||||
$AdminUsername,
|
||||
(ConvertTo-SecureString $AdminPassword -AsPlainText -Force)
|
||||
)
|
||||
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
||||
|
||||
try {
|
||||
$cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
|
||||
if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) {
|
||||
$newHosts = if ($cur) { "$cur,$VMIPAddress" } else { $VMIPAddress }
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newHosts -Force
|
||||
}
|
||||
|
||||
Write-Host "`nValidating full Setup state on $VMIPAddress..." -ForegroundColor Cyan
|
||||
|
||||
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
|
||||
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
|
||||
|
||||
param($BuildUsername, $DotNetChannel, $PythonVersion)
|
||||
|
||||
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
function Chk {
|
||||
param([string]$Name, [scriptblock]$Test)
|
||||
try { $ok = [bool](& $Test); $err = '' }
|
||||
catch { $ok = $false; $err = $_.Exception.Message }
|
||||
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# DEPLOY checks (same as Validate-DeployState.ps1)
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Firewall
|
||||
foreach ($p in Get-NetFirewallProfile) {
|
||||
$n = $p.Name; $e = $p.Enabled
|
||||
Chk "Firewall $n disabled" { $e -eq $false }
|
||||
}
|
||||
|
||||
# Defender
|
||||
Chk 'Defender GPO DisableAntiSpyware=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' `
|
||||
-Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1
|
||||
}
|
||||
|
||||
# WinRM
|
||||
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
|
||||
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
|
||||
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' {
|
||||
(Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false'
|
||||
}
|
||||
Chk 'WinRM Auth/Basic=true' {
|
||||
(Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true'
|
||||
}
|
||||
Chk 'WinRM MaxMemoryPerShellMB >= 2048' {
|
||||
[int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048
|
||||
}
|
||||
|
||||
# UAC
|
||||
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
Chk 'UAC EnableLUA=0' {
|
||||
(Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0
|
||||
}
|
||||
Chk 'UAC LocalAccountTokenFilterPolicy=1' {
|
||||
(Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1
|
||||
}
|
||||
|
||||
# Explorer
|
||||
Chk 'Explorer LaunchTo=1 (HKCU)' {
|
||||
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' `
|
||||
-Name LaunchTo -EA Stop).LaunchTo -eq 1
|
||||
}
|
||||
|
||||
# UX hardening
|
||||
Chk 'NoLockScreen=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' `
|
||||
-Name NoLockScreen -EA Stop).NoLockScreen -eq 1
|
||||
}
|
||||
Chk 'Server Manager policy DoNotOpenAtLogon=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' `
|
||||
-Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1
|
||||
}
|
||||
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' `
|
||||
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
|
||||
}
|
||||
Chk 'Server Manager task Disabled' {
|
||||
$t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' `
|
||||
-TaskName 'ServerManager' -EA SilentlyContinue
|
||||
(-not $t) -or ($t.State -eq 'Disabled')
|
||||
}
|
||||
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' {
|
||||
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' `
|
||||
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
|
||||
}
|
||||
Chk 'DisableCAD=1 (Policies\System)' {
|
||||
(Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1
|
||||
}
|
||||
Chk 'OOBE DisablePrivacyExperience=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' `
|
||||
-Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1
|
||||
}
|
||||
Chk 'DataCollection AllowTelemetry=0' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' `
|
||||
-Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0
|
||||
}
|
||||
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' {
|
||||
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop
|
||||
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# SETUP checks (post-Prepare state)
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
# CI build user
|
||||
Chk "user $BuildUsername exists" {
|
||||
$null -ne (Get-LocalUser -Name $BuildUsername -EA Stop)
|
||||
}
|
||||
Chk "user $BuildUsername enabled" {
|
||||
(Get-LocalUser -Name $BuildUsername -EA Stop).Enabled
|
||||
}
|
||||
Chk "user $BuildUsername PasswordNeverExpires" {
|
||||
(Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null
|
||||
}
|
||||
Chk "user $BuildUsername member of Administrators" {
|
||||
[bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername)
|
||||
}
|
||||
|
||||
# CI directories
|
||||
foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') {
|
||||
Chk "$dir exists" { Test-Path $dir -PathType Container }
|
||||
}
|
||||
|
||||
# Windows Update permanently disabled
|
||||
Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' }
|
||||
Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' }
|
||||
Chk 'WU GPO NoAutoUpdate=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `
|
||||
-Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1
|
||||
}
|
||||
Chk 'WU GPO DisableWindowsUpdateAccess=1' {
|
||||
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
|
||||
-Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1
|
||||
}
|
||||
|
||||
# Toolchain
|
||||
Chk ".NET SDK channel $DotNetChannel" {
|
||||
$v = (& dotnet --version 2>&1) -as [string]
|
||||
$v -and $v.StartsWith($DotNetChannel)
|
||||
}
|
||||
Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' }
|
||||
Chk "Python version $PythonVersion" {
|
||||
$v = (& 'C:\Python\python.exe' --version 2>&1) -as [string]
|
||||
$v -and $v.Trim() -eq "Python $PythonVersion"
|
||||
}
|
||||
Chk 'MSBuild.exe present' {
|
||||
$msb = Get-Command msbuild -ErrorAction SilentlyContinue
|
||||
$msb -and (Test-Path $msb.Source)
|
||||
}
|
||||
|
||||
# Tier-1 Toolchain (§6.6)
|
||||
Chk 'Git for Windows (git.exe in PATH)' {
|
||||
Get-Command git -ErrorAction SilentlyContinue
|
||||
}
|
||||
Chk '7-Zip (7z.exe in PATH)' {
|
||||
Get-Command 7z -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# No leftover installer files in C:\CI root
|
||||
Chk 'no leftover installer scripts in C:\CI' {
|
||||
$leftovers = Get-ChildItem 'C:\CI' -File -EA SilentlyContinue |
|
||||
Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' }
|
||||
$leftovers.Count -eq 0
|
||||
}
|
||||
|
||||
return $results.ToArray()
|
||||
|
||||
} -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion
|
||||
|
||||
Write-Host ''
|
||||
$failed = 0
|
||||
$deployCnt = 0
|
||||
$setupCnt = 0
|
||||
$inSetup = $false
|
||||
|
||||
foreach ($r in $checks) {
|
||||
if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") {
|
||||
Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan
|
||||
$inSetup = $true
|
||||
}
|
||||
if ($r.Pass) {
|
||||
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
|
||||
} else {
|
||||
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
|
||||
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
if ($failed -eq 0) {
|
||||
Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $Remediate) {
|
||||
Write-Host "$failed / $($checks.Count) checks FAILED." -ForegroundColor Red
|
||||
Write-Host "Re-run with -Remediate to auto-fix known issues." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Remediation pass ─────────────────────────────────────────────────────
|
||||
$failedNames = @($checks | Where-Object { -not $_.Pass } | Select-Object -ExpandProperty Name)
|
||||
Write-Host "Applying remediation for $($failedNames.Count) failed check(s)..." -ForegroundColor Yellow
|
||||
|
||||
Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
|
||||
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
|
||||
|
||||
param([string[]] $FailedNames, [string] $AdminPassword)
|
||||
|
||||
if ($FailedNames -contains 'AutoAdminLogon=1, DefaultUserName=Administrator') {
|
||||
$wl = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
|
||||
Set-ItemProperty $wl AutoAdminLogon '1' -Type String
|
||||
Set-ItemProperty $wl DefaultUserName 'Administrator' -Type String
|
||||
Set-ItemProperty $wl DefaultDomainName '.' -Type String
|
||||
Set-ItemProperty $wl DefaultPassword $AdminPassword -Type String
|
||||
Write-Host " [FIX] AutoAdminLogon keys written."
|
||||
}
|
||||
if ($FailedNames -contains 'wuauserv StartType=Disabled') {
|
||||
Set-Service -Name wuauserv -StartupType Disabled -ErrorAction SilentlyContinue
|
||||
Write-Host " [FIX] wuauserv set to Disabled."
|
||||
}
|
||||
if ($FailedNames -contains 'UsoSvc StartType=Disabled') {
|
||||
Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue
|
||||
Write-Host " [FIX] UsoSvc set to Disabled."
|
||||
}
|
||||
|
||||
} -ArgumentList $failedNames, $AdminPassword
|
||||
|
||||
# ── Re-run checks ─────────────────────────────────────────────────────────
|
||||
Write-Host "`nRe-validating after remediation..." -ForegroundColor Cyan
|
||||
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
|
||||
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
|
||||
|
||||
param($BuildUsername, $DotNetChannel, $PythonVersion)
|
||||
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
function Chk {
|
||||
param([string]$Name, [scriptblock]$Test)
|
||||
try { $ok = [bool](& $Test); $err = '' }
|
||||
catch { $ok = $false; $err = $_.Exception.Message }
|
||||
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
|
||||
}
|
||||
foreach ($p in Get-NetFirewallProfile) { $n=$p.Name;$e=$p.Enabled; Chk "Firewall $n disabled" { $e -eq $false } }
|
||||
Chk 'Defender GPO DisableAntiSpyware=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' -Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1 }
|
||||
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
|
||||
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
|
||||
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' { (Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false' }
|
||||
Chk 'WinRM Auth/Basic=true' { (Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true' }
|
||||
Chk 'WinRM MaxMemoryPerShellMB >= 2048' { [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048 }
|
||||
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
|
||||
Chk 'UAC EnableLUA=0' { (Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0 }
|
||||
Chk 'UAC LocalAccountTokenFilterPolicy=1' { (Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1 }
|
||||
Chk 'Explorer LaunchTo=1 (HKCU)' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' -Name LaunchTo -EA Stop).LaunchTo -eq 1 }
|
||||
Chk 'NoLockScreen=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Name NoLockScreen -EA Stop).NoLockScreen -eq 1 }
|
||||
Chk 'Server Manager policy DoNotOpenAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' -Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1 }
|
||||
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 }
|
||||
Chk 'Server Manager task Disabled' { $t=Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' -TaskName 'ServerManager' -EA SilentlyContinue; (-not $t) -or ($t.State -eq 'Disabled') }
|
||||
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 }
|
||||
Chk 'DisableCAD=1 (Policies\System)' { (Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1 }
|
||||
Chk 'OOBE DisablePrivacyExperience=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' -Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1 }
|
||||
Chk 'DataCollection AllowTelemetry=0' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' -Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0 }
|
||||
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' { $wl=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop; $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' }
|
||||
Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' }
|
||||
Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' }
|
||||
Chk 'WU GPO NoAutoUpdate=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1 }
|
||||
Chk 'WU GPO DisableWindowsUpdateAccess=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' -Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1 }
|
||||
Chk "user $BuildUsername exists" { $null -ne (Get-LocalUser -Name $BuildUsername -EA Stop) }
|
||||
Chk "user $BuildUsername enabled" { (Get-LocalUser -Name $BuildUsername -EA Stop).Enabled }
|
||||
Chk "user $BuildUsername PasswordNeverExpires" { (Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null }
|
||||
Chk "user $BuildUsername member of Administrators" { [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) }
|
||||
foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') { Chk "$dir exists" { Test-Path $dir -PathType Container } }
|
||||
Chk ".NET SDK channel $DotNetChannel" { $v=(& dotnet --version 2>&1) -as [string]; $v -and $v.StartsWith($DotNetChannel) }
|
||||
Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' }
|
||||
Chk "Python version $PythonVersion" { $v=(& 'C:\Python\python.exe' --version 2>&1) -as [string]; $v -and $v.Trim() -eq "Python $PythonVersion" }
|
||||
Chk 'MSBuild.exe present' { $msb=Get-Command msbuild -EA SilentlyContinue; $msb -and (Test-Path $msb.Source) }
|
||||
Chk 'Git for Windows (git.exe in PATH)' { Get-Command git -ErrorAction SilentlyContinue }
|
||||
Chk '7-Zip (7z.exe in PATH)' { Get-Command 7z -ErrorAction SilentlyContinue }
|
||||
Chk 'no leftover installer scripts in C:\CI' { $l=Get-ChildItem 'C:\CI' -File -EA SilentlyContinue | Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' }; $l.Count -eq 0 }
|
||||
return $results.ToArray()
|
||||
} -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion
|
||||
|
||||
Write-Host ''
|
||||
$failed = 0
|
||||
$inSetup = $false
|
||||
foreach ($r in $checks) {
|
||||
if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") {
|
||||
Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan
|
||||
$inSetup = $true
|
||||
}
|
||||
if ($r.Pass) {
|
||||
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
|
||||
} else {
|
||||
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
|
||||
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
Write-Host ''
|
||||
if ($failed -eq 0) {
|
||||
Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green
|
||||
exit 0
|
||||
} else {
|
||||
Write-Host "$failed / $($checks.Count) checks still FAILED after remediation." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
} finally {
|
||||
Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,106 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pester v5 tests for scripts/New-BuildVM.ps1
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$script:ScriptPath = Join-Path $PSScriptRoot '..\scripts\New-BuildVM.ps1'
|
||||
$script:CloneBaseDir = Join-Path $env:TEMP "CI-Tests-CloneBase-$PID"
|
||||
New-Item -ItemType Directory -Path $script:CloneBaseDir -Force | Out-Null
|
||||
|
||||
# Fake vmrun.ps1 — exits with $env:FAKE_VMRUN_EXIT (default 0)
|
||||
# On success it also creates the destination VMX so the post-clone Test-Path passes.
|
||||
# Using .ps1 instead of .cmd avoids Windows cmd redirect limitations inside if blocks.
|
||||
$script:FakeVmrun = Join-Path $env:TEMP "fake-vmrun-$PID.ps1"
|
||||
Set-Content $script:FakeVmrun -Value @'
|
||||
# Args: -T ws clone <template> <dst_vmx> linked -snapshot <snapshot>
|
||||
$exitCode = if ($env:FAKE_VMRUN_EXIT) { [int]$env:FAKE_VMRUN_EXIT } else { 0 }
|
||||
if ($exitCode -eq 0) {
|
||||
# Create the destination VMX (5th positional arg) so Test-Path in caller passes.
|
||||
# Real vmrun creates the clone dir+file; we must do the same here.
|
||||
$dstVmx = $args[4] # -T ws clone <tmpl> <dst> ...
|
||||
$dstDir = Split-Path $dstVmx -Parent
|
||||
if (-not (Test-Path $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null }
|
||||
Set-Content $dstVmx ''
|
||||
}
|
||||
exit $exitCode
|
||||
'@
|
||||
|
||||
# Fake template VMX — just needs to exist
|
||||
$script:FakeTemplate = Join-Path $env:TEMP "fake-template-$PID.vmx"
|
||||
Set-Content $script:FakeTemplate -Value 'config.version = "8"'
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item $script:CloneBaseDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $script:FakeVmrun -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $script:FakeTemplate -Force -ErrorAction SilentlyContinue
|
||||
$env:FAKE_VMRUN_EXIT = $null
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'New-BuildVM — parameter validation' {
|
||||
It 'throws when TemplatePath does not exist' {
|
||||
{
|
||||
& $script:ScriptPath `
|
||||
-TemplatePath 'C:\DoesNotExist\template.vmx' `
|
||||
-CloneBaseDir $script:CloneBaseDir `
|
||||
-JobId 'test-job-1' `
|
||||
-VmrunPath $script:FakeVmrun
|
||||
} | Should -Throw
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'New-BuildVM — vmrun failure' {
|
||||
It 'throws and cleans up clone dir when vmrun clone exits non-zero' {
|
||||
$env:FAKE_VMRUN_EXIT = '1'
|
||||
{
|
||||
& $script:ScriptPath `
|
||||
-TemplatePath $script:FakeTemplate `
|
||||
-CloneBaseDir $script:CloneBaseDir `
|
||||
-JobId 'test-job-fail' `
|
||||
-VmrunPath $script:FakeVmrun
|
||||
} | Should -Throw -ExpectedMessage '*clone failed*'
|
||||
|
||||
# No leftover clone dir
|
||||
$leftover = Get-ChildItem $script:CloneBaseDir -Directory |
|
||||
Where-Object { $_.Name -like '*test-job-fail*' }
|
||||
$leftover | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
AfterEach { $env:FAKE_VMRUN_EXIT = $null }
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'New-BuildVM — clone name format' {
|
||||
It 'clone folder is named Clone_{JobId}_{yyyyMMdd_HHmmss}' {
|
||||
$env:FAKE_VMRUN_EXIT = '0'
|
||||
$vmx = & $script:ScriptPath `
|
||||
-TemplatePath $script:FakeTemplate `
|
||||
-CloneBaseDir $script:CloneBaseDir `
|
||||
-JobId 'run-42' `
|
||||
-VmrunPath $script:FakeVmrun
|
||||
|
||||
$vmx | Should -Match 'Clone_run-42_\d{8}_\d{6}'
|
||||
}
|
||||
|
||||
It 'returns a string ending in .vmx' {
|
||||
$env:FAKE_VMRUN_EXIT = '0'
|
||||
$vmx = & $script:ScriptPath `
|
||||
-TemplatePath $script:FakeTemplate `
|
||||
-CloneBaseDir $script:CloneBaseDir `
|
||||
-JobId 'run-99' `
|
||||
-VmrunPath $script:FakeVmrun
|
||||
|
||||
$vmx | Should -Match '\.vmx$'
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
$env:FAKE_VMRUN_EXIT = $null
|
||||
# Clean up clones created by successful tests
|
||||
Get-ChildItem $script:CloneBaseDir -Directory -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pester v5 tests for scripts/Remove-BuildVM.ps1
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$script:ScriptPath = Join-Path $PSScriptRoot '..\scripts\Remove-BuildVM.ps1'
|
||||
|
||||
# Fake vmrun: accepts any args, always exits 0 (best-effort ops don't need to fail here)
|
||||
$script:FakeVmrun = Join-Path $env:TEMP "fake-vmrun-remove-$PID.cmd"
|
||||
Set-Content $script:FakeVmrun -Value '@echo off & exit /b 0'
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item $script:FakeVmrun -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Remove-BuildVM — missing VMX' {
|
||||
It 'returns without error when VMX does not exist' {
|
||||
{ & $script:ScriptPath -VMPath 'C:\DoesNotExist\clone.vmx' -VmrunPath $script:FakeVmrun } |
|
||||
Should -Not -Throw
|
||||
}
|
||||
|
||||
It 'removes partial clone directory even when VMX is missing' {
|
||||
$partialDir = Join-Path $env:TEMP "partial-clone-$PID"
|
||||
New-Item -ItemType Directory -Path $partialDir -Force | Out-Null
|
||||
$fakeMissingVmx = Join-Path $partialDir 'missing.vmx'
|
||||
|
||||
& $script:ScriptPath -VMPath $fakeMissingVmx -VmrunPath $script:FakeVmrun
|
||||
|
||||
Test-Path $partialDir | Should -Be $false
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Remove-BuildVM — WhatIf' {
|
||||
It 'does not remove clone directory when -WhatIf is passed' {
|
||||
$cloneDir = Join-Path $env:TEMP "whatif-clone-$PID"
|
||||
$cloneVmx = Join-Path $cloneDir 'whatif-clone.vmx'
|
||||
New-Item -ItemType Directory -Path $cloneDir -Force | Out-Null
|
||||
Set-Content $cloneVmx -Value 'config.version = "8"'
|
||||
|
||||
& $script:ScriptPath -VMPath $cloneVmx -VmrunPath $script:FakeVmrun -WhatIf
|
||||
|
||||
Test-Path $cloneDir | Should -Be $true
|
||||
|
||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Remove-BuildVM — successful destroy' {
|
||||
It 'removes the clone directory when VMX exists and vmrun succeeds' {
|
||||
$cloneDir = Join-Path $env:TEMP "live-clone-$PID"
|
||||
$cloneVmx = Join-Path $cloneDir 'live-clone.vmx'
|
||||
New-Item -ItemType Directory -Path $cloneDir -Force | Out-Null
|
||||
Set-Content $cloneVmx -Value 'config.version = "8"'
|
||||
|
||||
& $script:ScriptPath `
|
||||
-VMPath $cloneVmx `
|
||||
-VmrunPath $script:FakeVmrun `
|
||||
-GracefulTimeoutSeconds 1 # short timeout for test speed
|
||||
|
||||
Test-Path $cloneDir | Should -Be $false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pester v5 tests for scripts/Wait-VMReady.ps1
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
$script:ScriptPath = Join-Path $PSScriptRoot '..\scripts\Wait-VMReady.ps1'
|
||||
|
||||
# Fake vmrun: getGuestIPAddress exits with %FAKE_VMRUN_EXIT% (default 1 = not ready)
|
||||
$script:FakeVmrun = Join-Path $env:TEMP "fake-vmrun-wait-$PID.cmd"
|
||||
Set-Content $script:FakeVmrun -Value @'
|
||||
@echo off
|
||||
if "%FAKE_VMRUN_EXIT%"=="" ( exit /b 1 ) else ( exit /b %FAKE_VMRUN_EXIT% )
|
||||
'@
|
||||
|
||||
# Fake VMX file — just needs to exist
|
||||
$script:FakeVmx = Join-Path $env:TEMP "fake-wait-$PID.vmx"
|
||||
Set-Content $script:FakeVmx -Value 'config.version = "8"'
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item $script:FakeVmrun -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $script:FakeVmx -Force -ErrorAction SilentlyContinue
|
||||
$env:FAKE_VMRUN_EXIT = $null
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Wait-VMReady — missing vmrun' {
|
||||
It 'throws when VmrunPath does not exist' {
|
||||
{
|
||||
& $script:ScriptPath `
|
||||
-VMPath $script:FakeVmx `
|
||||
-IPAddress '192.168.79.101' `
|
||||
-VmrunPath 'C:\DoesNotExist\vmrun.exe'
|
||||
} | Should -Throw
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Wait-VMReady — timeout' {
|
||||
It 'throws with timeout message when VM never becomes ready' {
|
||||
$env:FAKE_VMRUN_EXIT = '1' # vmrun getGuestIPAddress always fails → VM not running
|
||||
{
|
||||
& $script:ScriptPath `
|
||||
-VMPath $script:FakeVmx `
|
||||
-IPAddress '192.168.79.101' `
|
||||
-VmrunPath $script:FakeVmrun `
|
||||
-TimeoutSeconds 5 `
|
||||
-PollIntervalSeconds 2 `
|
||||
-SkipPing
|
||||
} | Should -Throw -ExpectedMessage '*Timed out*'
|
||||
}
|
||||
|
||||
AfterEach { $env:FAKE_VMRUN_EXIT = $null }
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Wait-VMReady — IP validation' {
|
||||
It 'rejects an invalid IP address' {
|
||||
{
|
||||
& $script:ScriptPath `
|
||||
-VMPath $script:FakeVmx `
|
||||
-IPAddress '999.999.999.999' `
|
||||
-VmrunPath $script:FakeVmrun
|
||||
} | Should -Throw
|
||||
}
|
||||
|
||||
It 'accepts a valid IP address without immediate error from parameter binding' {
|
||||
# This will still time out (vmrun fake exits 1), but the IP itself must not
|
||||
# cause a parameter validation error — that would throw before the timeout.
|
||||
$env:FAKE_VMRUN_EXIT = '1'
|
||||
$err = $null
|
||||
try {
|
||||
& $script:ScriptPath `
|
||||
-VMPath $script:FakeVmx `
|
||||
-IPAddress '192.168.79.101' `
|
||||
-VmrunPath $script:FakeVmrun `
|
||||
-TimeoutSeconds 3 `
|
||||
-PollIntervalSeconds 2 `
|
||||
-SkipPing `
|
||||
-ErrorAction Stop
|
||||
} catch {
|
||||
$err = $_
|
||||
}
|
||||
# Error must be the timeout, not a parameter validation error
|
||||
"$err" | Should -Match 'Timed out'
|
||||
"$err" | Should -Not -Match 'Cannot validate'
|
||||
|
||||
$env:FAKE_VMRUN_EXIT = $null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Pester v5 tests for scripts/_Common.psm1
|
||||
#>
|
||||
|
||||
BeforeAll {
|
||||
Import-Module (Join-Path $PSScriptRoot '..\scripts\_Common.psm1') -Force
|
||||
|
||||
# Fake vmrun: accepts any args, exits with %FAKE_VMRUN_EXIT% (default 0)
|
||||
$script:FakeVmrun = Join-Path $env:TEMP "fake-vmrun-$PID.cmd"
|
||||
Set-Content $script:FakeVmrun -Value @'
|
||||
@echo off
|
||||
echo fake-vmrun-output
|
||||
if "%FAKE_VMRUN_EXIT%"=="" ( exit /b 0 ) else ( exit /b %FAKE_VMRUN_EXIT% )
|
||||
'@
|
||||
}
|
||||
|
||||
AfterAll {
|
||||
Remove-Item $script:FakeVmrun -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'New-CISessionOption' {
|
||||
It 'returns a PSSessionOption' {
|
||||
$opt = New-CISessionOption
|
||||
$opt | Should -BeOfType [System.Management.Automation.Remoting.PSSessionOption]
|
||||
}
|
||||
|
||||
It 'has SkipCACheck = true' {
|
||||
(New-CISessionOption).SkipCACheck | Should -Be $true
|
||||
}
|
||||
|
||||
It 'has SkipCNCheck = true' {
|
||||
(New-CISessionOption).SkipCNCheck | Should -Be $true
|
||||
}
|
||||
|
||||
It 'has SkipRevocationCheck = true' {
|
||||
(New-CISessionOption).SkipRevocationCheck | Should -Be $true
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Resolve-VmrunPath' {
|
||||
It 'returns the path when the file exists' {
|
||||
$tmp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
Resolve-VmrunPath -VmrunPath $tmp | Should -Be $tmp
|
||||
} finally {
|
||||
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
It 'throws with a descriptive message when file is missing' {
|
||||
{ Resolve-VmrunPath -VmrunPath 'C:\DoesNotExist\vmrun.exe' } |
|
||||
Should -Throw -ExpectedMessage '*vmrun.exe not found*'
|
||||
}
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
Describe 'Invoke-Vmrun' {
|
||||
It 'returns ExitCode 0 and Output when command succeeds' {
|
||||
$env:FAKE_VMRUN_EXIT = '0'
|
||||
$r = Invoke-Vmrun -VmrunPath $script:FakeVmrun -Operation 'list'
|
||||
$r.ExitCode | Should -Be 0
|
||||
"$($r.Output)" | Should -Match 'fake-vmrun-output'
|
||||
}
|
||||
|
||||
It 'returns non-zero ExitCode without throwing by default' {
|
||||
$env:FAKE_VMRUN_EXIT = '1'
|
||||
$r = Invoke-Vmrun -VmrunPath $script:FakeVmrun -Operation 'clone'
|
||||
$r.ExitCode | Should -Be 1
|
||||
}
|
||||
|
||||
It 'throws when -ThrowOnError and ExitCode is non-zero' {
|
||||
$env:FAKE_VMRUN_EXIT = '1'
|
||||
{ Invoke-Vmrun -VmrunPath $script:FakeVmrun -Operation 'clone' -ThrowOnError } |
|
||||
Should -Throw -ExpectedMessage '*clone failed*'
|
||||
}
|
||||
|
||||
It 'does not throw when -ThrowOnError and ExitCode is 0' {
|
||||
$env:FAKE_VMRUN_EXIT = '0'
|
||||
{ Invoke-Vmrun -VmrunPath $script:FakeVmrun -Operation 'start' -ThrowOnError } |
|
||||
Should -Not -Throw
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
$env:FAKE_VMRUN_EXIT = $null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user