diff --git a/FIX-TODO.md b/FIX-TODO.md index fb78686..87ca992 100644 --- a/FIX-TODO.md +++ b/FIX-TODO.md @@ -1,110 +1,61 @@ # FIX-TODO — Local CI/CD System + -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`. diff --git a/README.md b/README.md index 3b7ffe0..fbb8112 100644 --- a/README.md +++ b/README.md @@ -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 "" -Persist LocalMachine ``` ### 3. act_runner diff --git a/Setup-Host.ps1 b/Setup-Host.ps1 index 2359b85..50f5aa1 100644 --- a/Setup-Host.ps1 +++ b/Setup-Host.ps1 @@ -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 ────────────────────────────────────────── diff --git a/TODO.md b/TODO.md index ecaeedb..f7ef95b 100644 --- a/TODO.md +++ b/TODO.md @@ -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 "" -Persist LocalMachine ``` - [x] Verifica `GITEA_CI_TEMPLATE_PATH` in `F:\CI\act_runner\config.yaml` — aggiornato a `CI-WinBuild.vmx` (2026-05-08) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85cb539..ae4ee48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/BEST-PRACTICES.md b/docs/BEST-PRACTICES.md index ec7c915..456d809 100644 --- a/docs/BEST-PRACTICES.md +++ b/docs/BEST-PRACTICES.md @@ -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. --- diff --git a/gitea/workflows/build-nsis.yml b/gitea/workflows/build-nsis.yml index 637f1e1..48a8166 100644 --- a/gitea/workflows/build-nsis.yml +++ b/gitea/workflows/build-nsis.yml @@ -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 diff --git a/gitea/workflows/lint.yml b/gitea/workflows/lint.yml new file mode 100644 index 0000000..4f7f4b2 --- /dev/null +++ b/gitea/workflows/lint.yml @@ -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." + } diff --git a/runner/Install-Runner.ps1 b/runner/Install-Runner.ps1 index 7c39faf..9efc4f3 100644 --- a/runner/Install-Runner.ps1 +++ b/runner/Install-Runner.ps1 @@ -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. diff --git a/scripts/Cleanup-OrphanedBuildVMs.ps1 b/scripts/Cleanup-OrphanedBuildVMs.ps1 new file mode 100644 index 0000000..e83edfa --- /dev/null +++ b/scripts/Cleanup-OrphanedBuildVMs.ps1 @@ -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." diff --git a/scripts/Get-BuildArtifacts.ps1 b/scripts/Get-BuildArtifacts.ps1 index 399e5d7..73da801 100644 --- a/scripts/Get-BuildArtifacts.ps1 +++ b/scripts/Get-BuildArtifacts.ps1 @@ -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 diff --git a/scripts/Invoke-CIJob.ps1 b/scripts/Invoke-CIJob.ps1 index ce66e8e..21e7936 100644 --- a/scripts/Invoke-CIJob.ps1 +++ b/scripts/Invoke-CIJob.ps1 @@ -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' diff --git a/scripts/Invoke-RemoteBuild.ps1 b/scripts/Invoke-RemoteBuild.ps1 index fc341f5..509a244 100644 --- a/scripts/Invoke-RemoteBuild.ps1 +++ b/scripts/Invoke-RemoteBuild.ps1 @@ -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 diff --git a/scripts/Wait-VMReady.ps1 b/scripts/Wait-VMReady.ps1 index d13d642..48ce63d 100644 --- a/scripts/Wait-VMReady.ps1 +++ b/scripts/Wait-VMReady.ps1 @@ -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 { - & $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null - $isRunning = ($LASTEXITCODE -eq 0) - } - catch { - $isRunning = $false - } + # 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) 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 } diff --git a/template/Prepare-TemplateSetup.ps1 b/template/Prepare-TemplateSetup.ps1 index 0a193d0..1e0edfe 100644 --- a/template/Prepare-TemplateSetup.ps1 +++ b/template/Prepare-TemplateSetup.ps1 @@ -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 '' -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 '' ``" 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." } diff --git a/template/Setup-TemplateVM.ps1 b/template/Setup-TemplateVM.ps1 index b854b5a..b941d79 100644 --- a/template/Setup-TemplateVM.ps1 +++ b/template/Setup-TemplateVM.ps1 @@ -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,11 +187,9 @@ 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 +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 @@ -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 '' -Persist LocalMachine" Write-Host " (Run in an elevated PowerShell with CredentialManager module)" Write-Host ""