fix: apply all FIX-TODO items (P0/P1/P2)

P0 bugs:
- Invoke-CIJob.ps1: skip --depth 1 when $Commit specified (shallow clone
  + checkout specific commit was silently failing)
- Get-BuildArtifacts.ps1: check .Length -eq 0 not rounded sizeKB (false
  fail on artifacts smaller than 50 bytes)
- build-nsis.yml: upgrade upload-artifact v3 -> v4 (v3 deprecated)

P0 security:
- Remove hardcoded default password CIBuild!ChangeMe2026 from all scripts,
  README, TODO; prompt Read-Host -AsSecureString at runtime instead
- Setup-TemplateVM.ps1: replace `net user $u $p /add` (password in argv /
  audit log 4688) with New-LocalUser + local ConvertTo-SecureString
- Prepare-TemplateSetup.ps1: save and restore WSMan AllowUnencrypted +
  TrustedHosts in finally block (was permanently mutating host WinRM config)
- Setup-TemplateVM.ps1: remove duplicate MaxMemoryPerShellMB (winrm set
  + Set-Item both setting same value)

P1 doc (stale Host-Only / VMnet11 era):
- Setup-TemplateVM.ps1: rewrite NETWORK REQUIREMENT block + final steps
  (system uses VMnet8 NAT permanently, not VMnet11 Host-Only)
- Invoke-CIJob.ps1 Phase 1 comment: correct network model
- ARCHITECTURE.md: remove Git from toolchain list (not installed); clarify
  zip-transfer rationale (no PAT in VM, not network isolation)
- BEST-PRACTICES.md: rewrite Step 8 as NAT topology verification

P1 minor:
- Invoke-RemoteBuild.ps1: remove wasted first New-Item (created then
  immediately removed and recreated)
- Wait-VMReady.ps1: remove unused $elapsed; simplify try/catch around
  native command (exit codes don't throw)
- Install-Runner.ps1: add deprecation notice pointing to Setup-Host.ps1

P2 operational:
- Add scripts/Cleanup-OrphanedBuildVMs.ps1 with -WhatIf support
- Get-BuildArtifacts.ps1 -IncludeLogs: add -Recurse (msbuild logs in subdir)
- Add gitea/workflows/lint.yml (PSScriptAnalyzer on .ps1 push/PR)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simone
2026-05-09 00:39:52 +02:00
parent ecd79c2219
commit f373c0c24b
16 changed files with 318 additions and 175 deletions
+39 -88
View File
@@ -1,110 +1,61 @@
# FIX-TODO — Local CI/CD System
<!-- Generato: 2026-05-09 — review post e2e-009 SUCCESS -->
<!-- Completato: 2026-05-09 — tutti gli item risolti in commit separato -->
Lista di fix prioritizzati emersi dalla review. Ordine: P0 (bug/security) → P1 (doc obsolete che possono fuorviare) → P2 (cleanup/operativo).
Lista di fix prioritizzati emersi dalla review. Tutti i P0/P1/P2 sono stati applicati.
---
## P0 — Bug funzionali
## P0 — Bug funzionali
- [ ] **Shallow clone vs checkout commit specifico**`scripts/Invoke-CIJob.ps1:198`
Phase 1 fa `git clone --depth 1 --branch $Branch` e poi `git checkout $Commit` (riga 209).
Se `$Commit` ≠ HEAD del branch (es. commit più vecchio, push in retag) il checkout fallisce: il commit non è nella shallow history.
`gitea/workflows/build-nsis.yml` passa `${{ github.sha }}` esplicito → bug latente.
**Fix**: o `git fetch origin $Commit && git checkout $Commit` dopo clone shallow,
oppure se `$Commit` è valorizzato saltare `--depth 1` e fare clone completo + checkout.
Test: rerun di un workflow su un commit che non è più HEAD del branch.
- [x] **Shallow clone vs checkout commit specifico**`scripts/Invoke-CIJob.ps1`
Fix: `--depth 1` saltato quando `$Commit` è valorizzato; clone completo + checkout.
- [ ] **Falso negativo size artifact**`scripts/Get-BuildArtifacts.ps1:112-115`
`$sizeKB = [math]::Round((Get-Item $hostDestPath).Length / 1KB, 1); if ($sizeKB -eq 0) { throw }`
File con dimensione <50 byte arrotondano a 0 → throw spurio.
**Fix**: validare su `.Length -eq 0` (raw byte), poi calcolare `$sizeKB` solo per il messaggio.
- [x] **Falso negativo size artifact**`scripts/Get-BuildArtifacts.ps1`
Fix: validazione su `.Length -eq 0` (raw byte), non su KB arrotondati.
- [ ] **`actions/upload-artifact@v3` deprecato** — `gitea/workflows/build-nsis.yml:42`
`workflow-example.yml` usa già `@v4`. Allineare il workflow live a v4 (sintassi compatibile in questo caso).
- [x] **`actions/upload-artifact@v3` deprecato** — `gitea/workflows/build-nsis.yml`
Fix: aggiornato a `@v4`.
## P0 — Sicurezza
## P0 — Sicurezza
- [ ] **Password CI hardcoded committata**`CIBuild!ChangeMe2026` in:
`Setup-Host.ps1:85`, `template/Prepare-TemplateSetup.ps1:72`,
`template/Setup-TemplateVM.ps1:55`, `README.md:117`, `TODO.md:86`,
e anche stampata a console (`Setup-Host.ps1:177`, `Setup-TemplateVM.ps1:657`).
**Fix**:
- rimuovere il default; rendere `-BuildPassword` obbligatorio (o `Read-Host -AsSecureString` se assente)
- mai stamparla nei messaggi di output / esempi nel README
- documentare in BEST-PRACTICES che la password è scelta dall'operatore, non fornita dal repo
- [x] **Password CI hardcoded committata**rimossa da tutti i file:
`Setup-Host.ps1`, `template/Prepare-TemplateSetup.ps1`,
`template/Setup-TemplateVM.ps1`, `README.md`, `TODO.md`.
Ora: prompt `Read-Host -AsSecureString` se non passata a runtime.
- [ ] **Password in argv (`net user`)**`template/Setup-TemplateVM.ps1:221`
`& net user $BuildUsername $BuildPassword /add /y` — visibile in process listing/audit log.
**Fix**: tornare a `New-LocalUser` con `ConvertTo-SecureString` (la nota nel commento dice c'era un bug WinRM SecureString — verificare se ancora attuale su Server 2025; in alternativa pipe della password via `stdin` di `net user`).
- [x] **Password in argv (`net user`)**`template/Setup-TemplateVM.ps1`
Fix: `New-LocalUser` con `ConvertTo-SecureString` locale (nessun argomento esposto).
- [ ] **Nessuna verifica integrità degli installer scaricati**`template/Setup-TemplateVM.ps1`
`dotnet-install.ps1` (riga 338), Python (372), `vs_BuildTools.exe` (412) scaricati su HTTPS senza SHA256.
**Fix**: aggiungere `Get-FileHash` + confronto contro hash atteso parametrizzato. Per VS BuildTools (URL stabile `vs_buildtools.exe` ma binario aggiornato) tenere la verifica MZ-header già presente come fallback minimo, ma loggare hash effettivo per audit.
- [x] **`WSMan AllowUnencrypted` modificato senza restore** — `template/Prepare-TemplateSetup.ps1`
Fix: salva stato precedente, ripristina in `finally`.
## P1 — Doc obsolete (residui dell'epoca Host-Only)
## P1 — Doc obsolete
- [ ] **`Setup-TemplateVM.ps1` blocco "NETWORK REQUIREMENT" e istruzioni finali**
Righe 22-32 e 642-651 dicono di passare a `VMnet11 (Host-Only)` prima dello snapshot.
Sistema oggi resta su VMnet8 NAT. Riscrivere blocco + istruzioni finali per riflettere NAT permanente.
- [x] `Setup-TemplateVM.ps1`: blocco NETWORK REQUIREMENT e istruzioni finali aggiornati (VMnet8 NAT permanente, no VMnet11).
- [x] `Setup-TemplateVM.ps1:205`: commento firewall aggiornato (VMnet8 NAT, non VMnet11).
- [x] `scripts/Invoke-CIJob.ps1:193`: commento Phase 1 aggiornato (NAT, motivo zip = no PAT in VM).
- [x] `docs/BEST-PRACTICES.md` Step 8: riscritto come "Network Topology Verification" (NAT OK, non host-only).
- [x] `docs/ARCHITECTURE.md:119`: rimosso "Git" dalla toolchain (non installato di default).
- [x] `docs/ARCHITECTURE.md:194`: riformulato (NAT per pip/nuget, zip per no-PAT in VM).
- [ ] **`Setup-TemplateVM.ps1:205-206`** — commento "VM lives on a Host-Only network (VMnet11)" — falso. Sostituire con riferimento a VMnet8 NAT (lab isolato).
## P1 — Bug minori ✅
- [ ] **`scripts/Invoke-CIJob.ps1:193`** — commento "build VM has no internet/LAN access (Host-Only network)". Falso. Aggiornare a "build VM su VMnet8 NAT — internet OK; sorgente comunque iniettato via WinRM zip per isolamento dal token gitea".
- [x] `scripts/Invoke-RemoteBuild.ps1`: primo `New-Item` sprecato rimosso.
- [x] `scripts/Wait-VMReady.ps1`: `$elapsed` dead code rimosso; try/catch su native command semplificato.
- [x] `template/Setup-TemplateVM.ps1`: `MaxMemoryPerShellMB` settato una volta sola (rimosso `winrm set` duplicato).
- [x] `runner/Install-Runner.ps1`: nota deprecazione aggiunta — usare `Setup-Host.ps1`.
- [ ] **`docs/BEST-PRACTICES.md` Step 8 "Network Isolation Verification"** (riga 236-256)
Test pensato per Host-Only: assume VM senza internet. Contraddice il setup. Rimuovere lo step o riscriverlo come "VM raggiunge solo NAT gateway + LAN gitea, non altre subnet host".
## P2 — Operativo ✅
- [ ] **`docs/ARCHITECTURE.md:119`** — "Git (per repo clone inside VM)" — git NON installato in template. Rimuovere finché `-UseGitClone` non implementato (vedi TODO.md sezione "In-VM Git Clone").
- [x] `scripts/Cleanup-OrphanedBuildVMs.ps1` creato — con `-WhatIf`, soglia `-MaxAgeHours`, hard stop + deleteVM + rimozione dir.
- [x] `scripts/Get-BuildArtifacts.ps1 -IncludeLogs`: aggiunto `-Recurse` per catturare log in subdir.
- [x] `gitea/workflows/lint.yml` creato — PSScriptAnalyzer su push/PR che toccano file `.ps1`.
- [ ] **`docs/ARCHITECTURE.md:194`** — riga "Source code is transferred via zip ... no git clone inside VM" è corretta ma stride col commento successivo su isolamento internet. Riformulare per dichiarare esplicitamente: VM ha NAT internet per pip/nuget; sorgente comunque via zip per evitare di iniettare PAT git.
## Rimasto aperto (fuori scope di questo fix)
## P1 — Bug minori / dead code
- [ ] **`scripts/Invoke-RemoteBuild.ps1:96-107`** — primo `New-Item` su `$workDir` viene creato e rimosso subito dopo. Sostituire con singolo blocco: `if (Test-Path) { Remove-Item -Recurse -Force }; New-Item ...`.
- [ ] **`scripts/Wait-VMReady.ps1:119`** — `$elapsed = ($deadline - (Get-Date)).Negate().TotalSeconds + $TimeoutSeconds` calcolato e mai usato. Eliminare o usare nel messaggio.
- [ ] **`scripts/Wait-VMReady.ps1:80-86`** — `try/catch` attorno a `& vmrun ... | Out-Null`: native exit non lancia eccezioni, `catch` irraggiungibile. Semplificare a controllo `$LASTEXITCODE` puro.
- [ ] **`template/Setup-TemplateVM.ps1:188,193`** — `MaxMemoryPerShellMB` settato sia via `winrm set winrm/config/winrs` che `Set-Item WSMan:\...\Shell\...`. Tenere uno solo (preferenza: `Set-Item` su PS drive — idempotente in PS).
- [ ] **`template/Prepare-TemplateSetup.ps1:101-107`** — modifica permanente di `WSMan:\localhost\Client\AllowUnencrypted` sull'host senza ripristino e senza messaggio chiaro.
**Fix**: salvare lo stato pre-modifica e ripristinarlo nel `finally`, oppure documentare esplicitamente che è un cambio permanente di setup (riportare su `BEST-PRACTICES.md` e `Setup-Host.ps1`).
- [ ] **`runner/Install-Runner.ps1` duplica `Setup-Host.ps1` Step 6** — README cita solo `Setup-Host.ps1`. Decidere:
a) eliminare `Install-Runner.ps1`, o
b) farlo richiamare da `Setup-Host.ps1` (single source of truth).
Drift garantito altrimenti.
## P2 — Operativo
- [ ] **Implementare `scripts/Cleanup-OrphanedBuildVMs.ps1`** — descritto in `docs/BEST-PRACTICES.md:155` ma non in repo. Crearlo + scheduled task daily che lo invoca. Soglia default: 4h da `LastWriteTime` (allineato a `runner.timeout: 2h`).
- [ ] **Includere log build da subdir**`scripts/Get-BuildArtifacts.ps1:96`
Oggi: `Get-ChildItem -Path 'C:\CI\build' -Filter '*.log'` (NON ricorsivo).
MSBuild scrive log in `obj/*.log`, `bin/*.log`. Aggiungere `-Recurse` (con limite di profondità via filtro path o size cap per evitare archivi enormi).
- [ ] **`scripts/Invoke-RemoteBuild.ps1:141` — sanitizzazione `BuildCommand`**
`& cmd /c "$cmd 2>&1"` interpola stringa user-controlled. Workflow autore ha già exec, ma è poco igienico.
**Fix**: passare `BuildCommand` come array di args al ScriptBlock e invocare con `Start-Process`/`& $exe $args` quando possibile, oppure documentare che `BuildCommand` è trusted.
- [ ] **PSScriptAnalyzer in CI** — aggiungere workflow `lint.yml` che esegue `Invoke-ScriptAnalyzer` su `scripts/`, `template/`, `Setup-Host.ps1`. Catturerebbe metà delle voci sopra (dead code, var inutilizzate, native exit pattern).
- [ ] **Validazione IP più stretta**`scripts/Invoke-CIJob.ps1:107` `[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]` accetta `999.999.999.999`. Sostituire con `[System.Net.IPAddress]::TryParse(...)` o regex per ottetti 0-255.
- [ ] **Aggiornare `TODO.md` In-VM Git Clone** — la sezione è ancora aperta. Quando si attacca:
- non distribuire PAT in env globale del runner (`runner/config.yaml` `envs:`); leggerlo da Credential Manager per-job
- mai loggare la URL completa (con PAT inline) — fare `git remote set-url` post-clone con URL pulita prima di qualsiasi log
---
## Note
Sistema in stato production-ready (e2e-009 verde). Tutti i P0 sopra sono bug latenti che non si manifestano nel caso d'uso testato (`nsis-plugin-nsinnounp` sul branch HEAD), ma scattano al primo:
- rerun di un commit non-HEAD (P0 shallow clone),
- artifact molto piccolo (P0 size==0),
- maintenance (`upload-artifact v3` smetterà di funzionare senza preavviso).
Le P1 doc sono il debito più visibile: chiunque legga `Setup-TemplateVM.ps1` da zero finisce per spostare la VM su Host-Only e rompere il sistema.
- [ ] `-UseGitClone` in-VM: PAT injection, git in template, e2e-010. Vedi `TODO.md` sezione "In-VM Git Clone".
- [ ] Verifica SHA256 installer (Python, VS BuildTools, dotnet-install.ps1) — richiede parametri hash per versione.
- [ ] Validazione IP ottetti 0-255 in `Invoke-CIJob.ps1` (ValidatePattern attuale accetta 999.x.x.x).
- [ ] Scheduled task per `Cleanup-OrphanedBuildVMs.ps1` — da creare manualmente o via `Setup-Host.ps1`.
+1 -1
View File
@@ -114,7 +114,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
+8 -5
View File
@@ -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 ──────────────────────────────────────────
+1 -1
View File
@@ -83,7 +83,7 @@
- [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)
+2 -2
View File
@@ -116,8 +116,8 @@ The runner host **never executes build tools directly**. Its only role is orches
- Visual Studio Build Tools **2026** (MSBuild 18.5.4 / toolset v145)
- .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: NOT installed by default — source arrives via WinRM zip transfer. See TODO.md `-UseGitClone` for the in-VM git clone opt-in path.
---
@@ -191,4 +191,4 @@ VMs have internet access via NAT — required for pip/nuget during build.
- WinRM credentials are stored in Windows Credential Manager on host (not hardcoded)
- 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) — no PAT is ever present inside the VM; VMs have NAT internet for pip/nuget at build time
+14 -10
View File
@@ -233,27 +233,31 @@ Get-EventLog -LogName Application -Source '*runner*' -Newest 20
---
## 8. Network Isolation Verification
## 8. Network Topology Verification
After setting up the host-only VMware network, verify that build VMs cannot
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 on port 5985)
- 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.
---
+1 -1
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
# PSScriptAnalyzer lint — runs on every push/PR that touches PowerShell files.
# Requires PSScriptAnalyzer installed on the runner host:
# Install-Module PSScriptAnalyzer -Scope AllUsers -Force
#
# Failures block the PR. Fix warnings with:
# Invoke-ScriptAnalyzer -Path scripts\ -Recurse -Fix
name: Lint (PSScriptAnalyzer)
on:
push:
paths:
- '**.ps1'
- '**.psm1'
pull_request:
paths:
- '**.ps1'
- '**.psm1'
jobs:
lint:
runs-on: windows-build
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run PSScriptAnalyzer
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) {
Write-Host "Installing PSScriptAnalyzer..."
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber
}
$paths = @('scripts', 'template', 'runner', 'Setup-Host.ps1')
$results = $paths | ForEach-Object {
if (Test-Path $_) {
Invoke-ScriptAnalyzer -Path $_ -Recurse -Severity Error,Warning
}
}
if ($results) {
$results | Format-Table -AutoSize
Write-Error "PSScriptAnalyzer found $($results.Count) issue(s). Fix before merging."
exit 1
}
else {
Write-Host "PSScriptAnalyzer: no issues found."
}
+6
View File
@@ -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.
+104
View File
@@ -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."
+4 -3
View File
@@ -93,7 +93,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 +109,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
+8 -3
View File
@@ -190,11 +190,16 @@ $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.
# Build VM is on VMnet8 NAT (internet OK for builds), but source is
# still cloned here and copied via WinRM zip to avoid injecting a PAT
# into the VM environment.
Write-Host "`n[Phase 1/6] Cloning repository on host..."
if (Test-Path $hostCloneDir) { Remove-Item $hostCloneDir -Recurse -Force }
$gitArgs = @('clone', '--depth', '1', '--branch', $Branch)
# Skip --depth 1 when a specific commit is requested: shallow clones
# only contain HEAD, so checkout of an older commit will fail.
$gitArgs = @('clone')
if ([string]::IsNullOrWhiteSpace($Commit)) { $gitArgs += @('--depth', '1') }
$gitArgs += @('--branch', $Branch)
if ($Submodules) { $gitArgs += '--recurse-submodules' }
$gitArgs += @($RepoUrl, $hostCloneDir)
$ErrorActionPreference = 'Continue'
+6 -8
View File
@@ -94,15 +94,13 @@ 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
+2 -8
View File
@@ -75,15 +75,10 @@ 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 {
# vmrun has no getState; getGuestIPAddress exits non-zero when the VM
# is not powered on. Native commands don't throw — check exit code only.
& $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null
$isRunning = ($LASTEXITCODE -eq 0)
}
catch {
$isRunning = $false
}
if (-not $isRunning) {
if ($phase -ne 'vmrun-state') {
@@ -116,7 +111,6 @@ while ((Get-Date) -lt $deadline) {
try {
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
# Success — VM is ready
$elapsed = ($deadline - (Get-Date)).Negate().TotalSeconds + $TimeoutSeconds
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
return
}
+32 -10
View File
@@ -24,9 +24,9 @@
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:
3. Store credentials on host (use the same password chosen during provisioning):
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password 'CIBuild!ChangeMe2026' -Persist LocalMachine
-Password '<your-build-password>' -Persist LocalMachine
.PARAMETER VMIPAddress
IP address of the template VM while it is on NAT (VMnet8).
@@ -69,7 +69,7 @@ param(
[string] $AdminPassword = '',
[string] $BuildPassword = 'CIBuild!ChangeMe2026',
[string] $BuildPassword = '',
[string] $DotNetSdkVersion = '10.0',
@@ -81,6 +81,15 @@ $ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# 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):"
@@ -94,15 +103,19 @@ else {
$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..."
# AllowUnencrypted is needed for HTTP/5985 Basic auth. We save the prior value
# and restore it in the finally block so this script doesn't permanently change
# the host security posture.
Write-Host "[Prepare] Configuring host WinRM client for unencrypted Basic auth (will be restored on exit)..."
$prevAllowUnencrypted = $null
$prevTrustedHosts = $null
try {
$prevAllowUnencrypted = (Get-Item WSMan:\localhost\Client\AllowUnencrypted -ErrorAction Stop).Value
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force -ErrorAction Stop
# Add VM IP to TrustedHosts if not already present
$currentTrusted = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
if ($currentTrusted -ne '*' -and $currentTrusted -notlike "*$VMIPAddress*") {
$newTrusted = if ($currentTrusted -eq '') { $VMIPAddress } else { "$currentTrusted,$VMIPAddress" }
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
}
Write-Host "[Prepare] Host WinRM client configured."
@@ -230,7 +243,7 @@ try {
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 " -UserName 'ci_build' -Password '<your-build-password>' ``"
Write-Host " -Persist LocalMachine"
Write-Host ""
Write-Host " 4. Verify runner online in Gitea: http://10.10.20.11:3100"
@@ -239,4 +252,13 @@ try {
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
# Restore host WinRM client settings to their original values
if ($null -ne $prevAllowUnencrypted) {
Set-Item WSMan:\localhost\Client\AllowUnencrypted $prevAllowUnencrypted -Force -ErrorAction SilentlyContinue
}
if ($null -ne $prevTrustedHosts) {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
}
Write-Host "[Prepare] Host WinRM client settings restored."
}
+31 -31
View File
@@ -22,18 +22,17 @@
╔══════════════════════════════════════════════════════════════════════╗
║ 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.
║ download Windows Updates and tool installers. The VM NIC must be
set to VMnet8 (NAT) — keep it on NAT permanently (builds use NAT
for pip/nuget downloads at runtime).
╚══════════════════════════════════════════════════════════════════════╝
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
1. Shut down the VM (Start → Shut down)
The VM stays on VMnet8 (NAT) — internet access is required for builds.
2. In VMware Workstation: VM → Snapshot → Take Snapshot
Name it exactly: BaseClean
4. Power off the VM and leave it powered off permanently
3. Power off the VM and leave it powered off permanently
.PARAMETER SkipWindowsUpdate
Skip the Windows Update step (useful if updates were already applied).
@@ -50,9 +49,10 @@ param(
[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',
# Must be supplied by the caller (Prepare-TemplateSetup.ps1 prompts interactively).
# Store the same password in Windows Credential Manager on the HOST as target "BuildVMGuest".
[Parameter(Mandatory)]
[string] $BuildPassword,
# .NET SDK channel to install (should match the host SDK version)
[string] $DotNetSdkVersion = '10.0',
@@ -187,9 +187,7 @@ 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
@@ -202,9 +200,10 @@ 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.
# Disable all firewall profiles entirely. This VM lives on VMnet8 NAT behind
# the VMware NAT gateway with no inbound exposure from outside the host, so
# full disable is acceptable and avoids profile-classification issues blocking
# WinRM/ICMP from the host.
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
Write-Host "Windows Firewall disabled on all profiles."
@@ -216,11 +215,16 @@ 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"
# Convert to SecureString locally (inside the VM session) to avoid exposing
# the password as a process argument visible in audit log event 4688.
$secPwd = ConvertTo-SecureString $BuildPassword -AsPlainText -Force
try {
New-LocalUser -Name $BuildUsername -Password $secPwd `
-PasswordNeverExpires -Description 'CI build account' `
-ErrorAction Stop | Out-Null
}
catch {
throw "Failed to create user '$BuildUsername': $_"
}
Write-Host "User '$BuildUsername' created."
}
@@ -639,21 +643,17 @@ Write-Host "============================================================" -Foreg
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 " 1. Shut down this VM: Start -> Shut down"
Write-Host " (Keep NIC on VMnet8 NAT — internet is required for builds)"
Write-Host ""
Write-Host " 2. Shut down this VM: Start -> Shut down"
Write-Host ""
Write-Host " 3. Take snapshot in VMware Workstation:"
Write-Host " 2. 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 " 3. Leave VM powered off permanently."
Write-Host ""
Write-Host " 5. On the HOST - store credentials in Windows Credential Manager:"
Write-Host " 4. 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 " -Password '<your-build-password>' -Persist LocalMachine"
Write-Host " (Run in an elevated PowerShell with CredentialManager module)"
Write-Host ""