From dd238121b3bb0a44545b17883716da271ddd5df4 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 8 May 2026 23:25:50 +0200 Subject: [PATCH] chore: initial commit local CI/CD system (e2e verified, production-ready) Sistema CI locale basato su VMware Workstation + Gitea Actions + act_runner. Testato e2e con nsis-plugin-nsinnounp (MSBuild + Python, 4 configurazioni parallele). - scripts/: Invoke-CIJob, Invoke-RemoteBuild, New/Remove-BuildVM, Wait-VMReady, Get-BuildArtifacts - runner/: act_runner config (windows-build label, capacity 4) - gitea/workflows/: build-nsis.yml (template per progetti MSBuild/Python) - template/: script di provisioning template VM (VS BuildTools 2026, .NET SDK 10, Python 3.13) - docs/: ARCHITECTURE, CI-FLOW, OPTIMIZATION, BEST-PRACTICES, Setup-GiteaSSH Verificato: e2e-009 SUCCESS in 02:09, cleanup automatico VM confermato. --- .gitignore | 44 ++ PLAN.md | 44 ++ Setup-TemplateVM.ps1 | 1 + TODO.md | 152 +++++++ docs/ARCHITECTURE.md | 193 +++++++++ docs/BEST-PRACTICES.md | 281 ++++++++++++ docs/CI-FLOW.md | 254 +++++++++++ docs/OPTIMIZATION.md | 197 +++++++++ docs/Setup-GiteaSSH.md | 101 +++++ gitea/workflow-example.yml | 124 ++++++ gitea/workflows/build-nsis.yml | 46 ++ runner/Install-Runner.ps1 | 201 +++++++++ runner/config.yaml | 82 ++++ scripts/Get-BuildArtifacts.ps1 | 122 ++++++ scripts/Invoke-CIJob.ps1 | 281 ++++++++++++ scripts/Invoke-RemoteBuild.ps1 | 202 +++++++++ scripts/New-BuildVM.ps1 | 114 +++++ scripts/Remove-BuildVM.ps1 | 100 +++++ scripts/Wait-VMReady.ps1 | 134 ++++++ template/Prepare-TemplateSetup.ps1 | 242 +++++++++++ template/Setup-TemplateVM.ps1 | 659 +++++++++++++++++++++++++++++ 21 files changed, 3574 insertions(+) create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 Setup-TemplateVM.ps1 create mode 100644 TODO.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BEST-PRACTICES.md create mode 100644 docs/CI-FLOW.md create mode 100644 docs/OPTIMIZATION.md create mode 100644 docs/Setup-GiteaSSH.md create mode 100644 gitea/workflow-example.yml create mode 100644 gitea/workflows/build-nsis.yml create mode 100644 runner/Install-Runner.ps1 create mode 100644 runner/config.yaml create mode 100644 scripts/Get-BuildArtifacts.ps1 create mode 100644 scripts/Invoke-CIJob.ps1 create mode 100644 scripts/Invoke-RemoteBuild.ps1 create mode 100644 scripts/New-BuildVM.ps1 create mode 100644 scripts/Remove-BuildVM.ps1 create mode 100644 scripts/Wait-VMReady.ps1 create mode 100644 template/Prepare-TemplateSetup.ps1 create mode 100644 template/Setup-TemplateVM.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..302dd3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Local CI/CD System — .gitignore + +# Secrets e credenziali — mai committare +*.credential +*.password +secrets/ +.env +.env.* + +# Log e output temporanei +logs/ +*.log + +# Artifact di build (storti su F:\CI\Artifacts, non nel repo) +artifacts/ + +# Directory di lavoro runtime (create da runner) +work/ +runner/work/ + +# File runner con registration token (generato da act_runner register) +runner/.runner + +# File VMware generati +*.vmx.lck/ +*.vmdk +*.nvram +*.vmsd +*.vmxf +*.vmem +*.vmsn + +# Windows +Thumbs.db +desktop.ini +$RECYCLE.BIN/ + +# VS Code +.vscode/settings.json +.vscode/launch.json + +# Temp +*.tmp +~$* diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..45a9b58 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,44 @@ +# Local CI/CD System — Design & Status + +## Sistema implementato e operativo (2026-05-08) + +### Hardware +- Windows 11 host — CPU i9-10900X, 64 GB RAM, NVMe SSD + +### Hypervisor +- VMware Workstation Pro +- Template VM: Windows Server 2025 + - VMX: `F:\CI\Templates\WinBuild\CI-WinBuild.vmx` + - Snapshot: `BaseClean` + - Toolchain: VS Build Tools 2026 (MSBuild 18.5.4 / v145), .NET SDK 10.0.203, Python 3.13.3 + +### CI System +- 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) + +### Architettura implementata +1. Gitea Actions enqueue job → act_runner lo prende +2. `Invoke-CIJob.ps1` orchestra 6 fasi: + - **Phase 1**: `git clone` repo (con submoduli) su host in `C:\Temp\ci-src-` + - **Phase 2**: `New-BuildVM.ps1` — linked clone da template snapshot `BaseClean` + - **Phase 3**: `vmrun start` VM headless + - **Phase 4**: `Wait-VMReady.ps1` — polling WinRM readiness (~5s) + - **Phase 5**: `Invoke-RemoteBuild.ps1` — trasferisce sorgente (zip), esegue build via WinRM, raccoglie `artifacts.zip` + - **Phase 6**: `Get-BuildArtifacts.ps1` — copia zip in `F:\CI\Artifacts\\` +3. `finally`: `Remove-BuildVM.ps1` — stop VM + rimozione directory clone + +### Caso d'uso implementato e testato +- Repo: `Simone/nsis-plugin-nsinnounp` (NSIS plugin C++ — MSBuild) +- Build command: `python build_plugin.py --final --dist-dir dist` +- 4 configurazioni parallele: x86-unicode, x64-unicode, cli-x86, cli-x64 +- Artifact: `dist/x86-unicode/nsInnoUnp.dll`, `dist/amd64-unicode/nsInnoUnp.dll`, `dist/nsInnoUnpCLI.exe`, `dist/nsInnoUnpCLI64.exe` +- e2e-009: SUCCESS in 02:09, cleanup automatico confermato + +### Fix critici applicati durante sviluppo +- **TRK0002 Windows quota exhaustion**: `build_plugin.py` divide thread count per numero di build parallele +- **MSBuild detection per VS 2026**: fix in `build_plugin.py` per vswhere con VS 2026 BuildTools +- **Output filter nascondeva errori**: filtro cambiato da substring a prefix match +- **--dist-dir**: nuovo flag per raccogliere artifact in cartella piatta pre-clean +- **--no-clean / --no-copy**: flag CI-friendly per skip cleanup e skip copia verso plugins dir \ No newline at end of file diff --git a/Setup-TemplateVM.ps1 b/Setup-TemplateVM.ps1 new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/Setup-TemplateVM.ps1 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fa4bb7c --- /dev/null +++ b/TODO.md @@ -0,0 +1,152 @@ +# TODO — Local CI/CD System + + + +## Setup & Infrastructure + +- [x] **Gitea Server** — già in esecuzione su `http://10.10.20.11:3100` (e `https://gitea.emulab.it`) + - [x] Runner registration token ottenuto e usato + - [x] Create at least one test repository with `.gitea/workflows/build.yml` + (`Simone/nsis-plugin-nsinnounp` — testato e2e, workflow in `gitea/workflows/build-nsis.yml`) + - [x] Gitea Actions abilitato (runner registrato con successo) + +- [x] **act_runner** — installato e registrato sull'host Windows + - [x] Binario `gitea-runner v1.0.2` scaricato in `F:\CI\act_runner\act_runner.exe` + - [x] Registrato con Gitea (`http://10.10.20.11:3100`) — runner ID 1, nome `local-windows-runner` + - [x] `config.yaml` copiato in `F:\CI\act_runner\config.yaml` con label `windows-build:host` + - [x] Installato come servizio Windows via NSSM — stato: **Running**, StartType: Automatic + - [x] Verificare che il runner appaia "Online" nell'UI Gitea Admin → Actions → Runners + - [x] Confermare riavvio automatico dopo reboot host + +- [x] **Directory CI** create su `F:\CI\` + - [x] `F:\CI\BuildVMs\` + - [x] `F:\CI\Artifacts\` + - [x] `F:\CI\act_runner\` + - [x] `F:\CI\Cache\NuGet\` + - [x] `F:\CI\RunnerWork\` + - [x] `F:\CI\Templates\WinBuild\` + - [x] `F:\CI\ISO\` — contiene `26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` (Windows Server 2025) + +- [x] **CredentialManager** PowerShell module v2.0 installato (Scope: CurrentUser) + +- [x] **Rete VM**: VMnet8 (NAT) — internet access per pip/nuget/npm durante i build + +## Template VM + +- [x] **Provision template VM** in VMware Workstation — segui questi passi nell'ordine esatto: + + ### 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` + - **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 + - [x] All'interno della VM (come Administrator), abilita WinRM: + ```cmd + winrm quickconfig -q + ``` + ```powershell + Enable-PSRemoting -Force -SkipNetworkProfileCheck + Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force + Set-Item WSMan:\localhost\Service\AllowUnencrypted $true -Force + Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force + ``` + - [x] **Attiva Windows Server 2025** (prima dello snapshot — i linked clone ereditano l'attivazione): + ```cmd + slmgr /dli + ``` + Se non attivato, scegli il metodo appropriato: + - **KMS** (server KMS in rete): `slmgr /skms ` poi `slmgr /ato` + - **MAK/Retail key**: `slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX` poi `slmgr /ato` + - **KMS pubblico** (alternativa lab): vedi https://github.com/robin113x/Windows-Server-Activation/ + Verifica: `slmgr /xpr` — deve mostrare attivazione permanente o scadenza lontana + - [x] Nota l'IP assegnato dal DHCP di VMnet8 (es. `192.168.79.x`) — `ipconfig` nella VM + + ### Fase B — Provisioning automatico dall'host + - [x] Dall'host, esegui: + ```powershell + cd n:\Code\Workspace\Local-CI-CD-System\template + .\Prepare-TemplateSetup.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. + - [x] Setup completato con successo (VS Build Tools 2026 exit code 0). + + ### 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`** + - [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 + ``` + - [x] Verifica `GITEA_CI_TEMPLATE_PATH` in `F:\CI\act_runner\config.yaml` — aggiornato a `CI-WinBuild.vmx` (2026-05-08) + +## Scripts Verification + +- [x] Test `New-BuildVM.ps1` standalone — clone `Clone_test-002` creato correttamente (2026-05-08) +- [x] Test `Wait-VMReady.ps1` standalone — exit 0 in ~5s, VM pronta (2026-05-08) +- [x] Test `Invoke-RemoteBuild.ps1` — dotnet restore + build OK, artifacts.zip creato in VM (2026-05-08) +- [x] Test `Get-BuildArtifacts.ps1` — artifacts.zip (78 KB) copiato in F:\CI\Artifacts\test-003\ (2026-05-08) +- [x] Test `Remove-BuildVM.ps1` — VM spenta + directory rimossa correttamente (2026-05-08) +- [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) + +## Runner Configuration + +- [x] `runner/config.yaml` aggiornato con percorsi `F:\CI\`, rete VMnet8 (NAT), label corretti +- [x] `capacity: 4` configurato (64 GB RAM / ~8 GB per VM) +- [x] Aggiornare `GITEA_CI_TEMPLATE_PATH` in `config.yaml` dopo creazione template VM +- [x] Archiviare credenziali guest in Windows Credential Manager (target: `BuildVMGuest`) + +## Gitea Workflow Integration + +- [x] Copiare `gitea/workflow-example.yml` in `.gitea/workflows/build.yml` nel repo di test + (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`) + +## 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) + +## Security Hardening (Post-MVP) + +- [ ] 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 + +--- + +## Reference Paths + +| Item | Path / Value | +| ------------------------- | ------------------------------------------------------------ | +| vmrun.exe | `C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe` | +| 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` | +| Snapshot name | `BaseClean` | +| Clone base dir | `F:\CI\BuildVMs\` | +| Artifact dir | `F:\CI\Artifacts\` | +| 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` — configurato e verificato | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..dfa7c0c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,193 @@ +# Architecture — Local CI/CD System + +## Overview + +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 + +The runner host **never executes build tools directly**. Its only role is orchestration: creating, driving, and destroying VMs. + +--- + +## System Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ DEVELOPER WORKSTATION │ +│ │ +│ git push → Gitea (self-hosted, LAN) │ +└───────────────────────────┬─────────────────────────────────────────────┘ + │ HTTP/HTTPS (Gitea Actions API) + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ WINDOWS HOST (i9-10900X · 64 GB RAM · NVMe SSD) │ +│ │ +│ ┌───────────────────┐ polls job queue ┌──────────────────┐ │ +│ │ Gitea Server │◄────────────────────────►│ act_runner │ │ +│ │ (Actions API) │ reports status/logs │ (Windows svc) │ │ +│ └───────────────────┘ └────────┬─────────┘ │ +│ │ │ +│ calls Invoke-CIJob.ps1 │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ 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 │ │ +│ └───────────────────────────────┬───────────────────────────────────┘ │ +│ │ vmrun.exe (VMware CLI) │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ VMware Workstation │ │ +│ │ │ │ +│ │ Template VM ──snapshot "BaseClean"──┐ │ │ +│ │ ├── Clone_Job_001.vmx │ │ +│ │ ├── Clone_Job_002.vmx │ │ +│ │ └── Clone_Job_003.vmx │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +│ │ WinRM :5985 (192.168.11.0/24) │ +└──────────────────────────────────┼──────────────────────────────────────┘ + │ + ┌────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Build VM #1 │ │ Build VM #2 │ │ Build VM #N │ +│ (ephemeral) │ │ (ephemeral) │ │ (ephemeral) │ +│ │ │ │ │ │ +│ VS Build Tools │ │ VS Build Tools │ │ VS Build Tools │ +│ .NET SDK 8 │ │ .NET SDK 8 │ │ .NET SDK 8 │ +│ Git │ │ Git │ │ Git │ +│ WinRM enabled │ │ WinRM enabled │ │ WinRM enabled │ +│ │ │ │ │ │ +│ > git clone │ │ > git clone │ │ > git clone │ +│ > dotnet build │ │ > dotnet build │ │ > dotnet build │ +│ > artifacts │ │ > artifacts │ │ > artifacts │ +│ │ │ │ │ │ +│ [DESTROYED] │ │ [DESTROYED] │ │ [DESTROYED] │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ +``` + +--- + +## Component Descriptions + +### Gitea (Self-hosted Git + CI) +- Hosts Git repositories +- Provides Gitea Actions (GitHub Actions-compatible YAML workflow engine) +- Manages job queue and reports build status to commits/PRs +- Stores build artifacts uploaded by act_runner + +### act_runner (Job Executor) +- Runs as a Windows service on the host +- Polls Gitea Actions API for pending jobs +- Executes `.gitea/workflows/*.yml` steps +- **Configured with `container.enabled: false`** — does NOT use Docker +- Each step runs as a PowerShell command on the host (orchestration only) +- Supports `capacity: N` for concurrent job execution (N parallel VMs) + +### VMware Workstation + vmrun CLI +- Manages the template VM and all ephemeral clones +- `vmrun.exe` is the command-line interface for all VM lifecycle operations +- **Linked clones** are used: each build VM is a delta clone from a frozen template snapshot + - Clone creation: ~5–15 seconds (vs 60–120 seconds for full clone) + - 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) +- 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` + +### 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) + +--- + +## Network Topology + +``` +Host NIC (LAN) → 192.168.1.x → Gitea server (10.10.20.11:3100), developer machines +VMware VMnet8 (NAT) → 192.168.79.0/24 → Build VMs (DHCP, internet access via NAT) + +Build VMs: + Clone_Job_001 → 192.168.79.131 + Clone_Job_002 → 192.168.79.132 + ... + 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. +``` + +> **Note:** VMnet8 NAT gives VMs internet access. The host detects the VM IP via +> `vmrun getGuestIPAddress` after boot — no static IP assignment needed. + +--- + +## VM Lifecycle + +``` +1. TEMPLATE (static, locked) + └─ Snapshot: "BaseClean" ───────────────────────┐ + │ +2. CLONE CREATION │ + vmrun clone linked ──►│ + Duration: ~5–15 seconds │ + │ +3. START │ + vmrun start nogui │ + Duration: ~20–40 seconds boot │ + │ +4. READINESS CHECK │ + Poll: getState=running → ping → Test-WSMan │ + Duration: ~30–90 seconds total │ + │ +5. BUILD │ + Copy script → Invoke-Command → collect artifacts │ + Duration: varies (seconds to minutes) │ + │ +6. DESTROY │ + vmrun stop hard -> vmrun deleteVM → Remove-Item │ + Duration: ~5–10 seconds │ + Result: zero disk footprint remaining ▼ +``` + +--- + +## Resource Limits & Parallel Capacity + +| Resource | Host Total | Per VM (est.) | Max VMs | +| --------------- | ---------------------- | --------------- | ---------------- | +| RAM | 64 GB | ~6–8 GB | 8–10 | +| CPU | 20 threads (i9-10900X) | ~4 vCPU | 5 | +| NVMe IOPS | High | Clone delta I/O | ~6–8 | +| **Recommended** | — | — | **4 concurrent** | + +`act_runner` `capacity: 4` is the conservative default. Raise to 6 after benchmarking. + +--- + +## Security Boundaries + +- Build VMs are on VMnet8 NAT (192.168.79.0/24) — host reaches VMs, VMs have internet access +- 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 diff --git a/docs/BEST-PRACTICES.md b/docs/BEST-PRACTICES.md new file mode 100644 index 0000000..a88bc28 --- /dev/null +++ b/docs/BEST-PRACTICES.md @@ -0,0 +1,281 @@ +# Best Practices — Stability, Security & Operations + +## 1. Credential Management + +### Do NOT store credentials in scripts or config files + +The scripts use `-GuestCredentialTarget` (a Windows Credential Manager target name) +rather than plaintext username/password parameters. Store credentials once: + +```powershell +# Run on host (once, before first CI job) +cmdkey /generic:BuildVMGuest /user:MACHINENAME\ci_build /pass:YourStrongPassword +``` + +Retrieve in scripts via the `CredentialManager` PowerShell module: + +```powershell +Install-Module CredentialManager -Scope CurrentUser +$cred = Get-StoredCredential -Target 'BuildVMGuest' +``` + +### Rotate credentials quarterly + +1. Update password in the template VM (requires rebuilding `BaseClean` snapshot) +2. Update Windows Credential Manager on the host: + ``` + cmdkey /generic:BuildVMGuest /user:MACHINENAME\ci_build /pass:NewPassword + ``` +3. No script changes required — they reference the target name, not the password. + +--- + +## 2. WinRM Security + +### Current setup (HTTP / port 5985) + +Acceptable for an **isolated lab network** where: +- Build VMs are on a dedicated host-only VMware network (192.168.11.0/24 — VMnet11 dedicated CI) +- No external traffic reaches port 5985 +- Credentials are managed via Windows Credential Manager + +### Recommended upgrade: WinRM over HTTPS (port 5986) + +```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 ` + -Credential $cred -SessionOption $sessionOptions +``` + +> `-SkipCACheck` is acceptable for a self-signed cert in an isolated lab. Do NOT +> use this against externally accessible machines. + +--- + +## 3. act_runner Service Stability + +### Windows Service Recovery Policy + +The `Install-Runner.ps1` script configures automatic service restart on failure: +- Restart after 1st failure: 5 seconds +- Restart after 2nd failure: 10 seconds +- Restart after subsequent: 30 seconds + +Verify in Services → act_runner → Properties → Recovery tab. + +### Monitor the service + +```powershell +# Check service status +Get-Service act_runner | Select-Object Status, StartType + +# View last 50 log lines +Get-EventLog -LogName Application -Source act_runner -Newest 50 | Format-List + +# Restart if needed +Restart-Service act_runner +``` + +### Scheduled health check (optional) + +Create a scheduled task that verifies the runner appears "Online" in Gitea via API: + +```powershell +# Check runner status via Gitea API every 15 minutes +$response = Invoke-RestMethod ` + -Uri "http://gitea.local/api/v1/admin/runners" ` + -Headers @{ Authorization = "token $env:GITEA_API_TOKEN" } + +$runnerOnline = $response | Where-Object { $_.name -eq 'local-windows-runner' -and $_.status -eq 'online' } +if (-not $runnerOnline) { + # Send alert (email, webhook, etc.) or restart service + Restart-Service act_runner +} +``` + +--- + +## 4. Template VM Integrity + +The "BaseClean" snapshot is the foundation of every build. If it is corrupted, +**all builds fail immediately**. + +### Protection measures + +1. **Never power on the template VM for reasons other than planned maintenance.** + Configure VMware Workstation to prevent accidental starts: right-click → Settings → + Options → Advanced → disable "Allow background snapshots". + +2. **Backup the parent VMDK before any template changes:** + ```powershell + # Before any template maintenance + $templateDir = 'F:\CI\Templates\WinBuild' + $backupDir = "F:\CI\Backups\Template_$(Get-Date -Format yyyyMMdd)" + Copy-Item $templateDir $backupDir -Recurse + ``` + +3. **Keep a list of all current linked clones** before refreshing the snapshot. + If any clone exists when you modify the parent, it may break. + Check: `vmrun list` — should return no build VMs during maintenance window. + +4. **Version the snapshot name** to make rollback easy: + Instead of reusing "BaseClean", name snapshots `BaseClean_20260101`. + Update `config.yaml` `envs.GITEA_CI_SNAPSHOT_NAME` when rotating. + +--- + +## 5. Orphaned VM Cleanup + +If the host loses power mid-job or act_runner crashes, ephemeral VMs may not be +destroyed. Run this cleanup script on host startup or as a daily scheduled task: + +```powershell +# Cleanup-OrphanedBuildVMs.ps1 +$vmrun = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' +$cloneBase = 'F:\CI\BuildVMs' +$maxAgeHours = 4 # No job should run longer than 4 hours + +Get-ChildItem $cloneBase -Directory | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddHours(-$maxAgeHours) } | + ForEach-Object { + $vmx = Get-ChildItem $_.FullName -Filter '*.vmx' | Select-Object -First 1 + if ($vmx) { + Write-Host "Cleaning orphan: $($vmx.FullName)" + & $vmrun -T ws stop $vmx.FullName hard 2>$null + & $vmrun -T ws deleteVM $vmx.FullName 2>$null + } + Remove-Item $_.FullName -Recurse -Force -ErrorAction SilentlyContinue + } +``` + +--- + +## 6. Gitea Repository Configuration + +### Required repository settings for workflows to run + +1. Enable Actions for the repository: Settings → Repository → Actions → Enable +2. Add secrets if needed: Settings → Secrets and Variables → Actions +3. Protect main branch: Settings → Branches → Branch protection rules + +### Workflow file location + +Workflows **must** be at `.gitea/workflows/*.yml` (not `.github/workflows/`). + +``` +your-repo/ +└── .gitea/ + └── workflows/ + └── build.yml ← copy from gitea/workflow-example.yml +``` + +--- + +## 7. Logging & Observability + +### act_runner logs + +The runner daemon writes to stdout (captured by the Windows service manager). +Increase verbosity for debugging: + +```yaml +# runner/config.yaml +log: + level: debug # change from "info" to "debug" + format: text +``` + +### Per-job build logs + +`Invoke-CIJob.ps1` outputs timestamped phase banners. act_runner captures all +stdout/stderr and uploads it to Gitea Actions → job log viewer. + +For persistent local logs: + +```powershell +# In your workflow YAML, redirect output to a log file: +- name: Build in ephemeral VM + shell: pwsh + run: | + .\scripts\Invoke-CIJob.ps1 ... *>&1 | Tee-Object -FilePath "F:\CI\Logs\job-${{ github.run_id }}.log" +``` + +### Windows Event Log + +act_runner (when installed as a service) writes events to Windows Event Log → +Application source "act_runner". Check with: + +```powershell +Get-EventLog -LogName Application -Source '*runner*' -Newest 20 +``` + +--- + +## 8. Network Isolation Verification + +After setting up the host-only VMware network, verify that build VMs cannot +reach the internet (important for supply-chain security): + +```powershell +# From inside a build VM via WinRM: +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" + } else { + Write-Host "VM correctly isolated (no internet)" + } +} +``` + +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) + +--- + +## 9. Updating the Build Toolchain + +When a new .NET SDK or VS Build Tools version is released: + +1. **During a maintenance window** (no CI jobs running): + ``` + vmrun list ← must be empty + ``` +2. Boot the template VM +3. Run updates: + ```powershell + # Update .NET SDK + & "C:\Users\ci_build\AppData\Local\Microsoft\dotnet\dotnet-install.ps1" -Channel 8.0 + + # Update VS Build Tools via Visual Studio Installer + "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe" update --quiet --norestart + ``` +4. Verify tools work (run a test build manually) +5. Shut down VM +6. Take new snapshot: `BaseClean_$(Get-Date -Format yyyyMMdd)` +7. Update `SnapshotName` in `runner/config.yaml` +8. Delete the old snapshot after confirming new one works for 1 week diff --git a/docs/CI-FLOW.md b/docs/CI-FLOW.md new file mode 100644 index 0000000..b6b4d2f --- /dev/null +++ b/docs/CI-FLOW.md @@ -0,0 +1,254 @@ +# CI Pipeline Flow — Step by Step + +## Trigger to Artifact: Complete Pipeline Walk-Through + +--- + +### Step 0 — Prerequisites (one-time setup) + +Before any CI job runs, the following must be in place: + +| Component | State | +| ------------------ | ----------------------------------------------------------------------- | +| 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) | +| 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 | + +--- + +### Step 1 — Developer Pushes Code + +``` +Developer → git push origin main +``` + +- Git push lands on Gitea server +- Gitea evaluates `.gitea/workflows/build.yml` in the repository +- If the workflow trigger matches (e.g. `on: push` to `main`), Gitea enqueues a new **Actions job** +- Job is tagged with the label defined in the workflow (`runs-on: windows-build`) + +--- + +### Step 2 — act_runner Picks Up the Job + +``` +act_runner (host) ─polling─► Gitea Actions API + ◄─job data─ +``` + +- act_runner polls the Gitea API approximately every 5–10 seconds +- Detects a pending job matching its labels (`windows-build`) +- Downloads the workflow YAML and job parameters +- If `capacity` slots are available, starts job execution immediately +- If all slots are busy, job waits in Gitea queue until a slot frees + +--- + +### Step 3 — act_runner Executes Workflow Steps + +The workflow YAML defines steps that run as PowerShell commands **on the host**. +The first substantive step calls `Invoke-CIJob.ps1`: + +```yaml +- name: Build in ephemeral VM + shell: pwsh + run: | + .\scripts\Invoke-CIJob.ps1 ` + -JobId "${{ github.run_id }}" ` + -RepoUrl "${{ env.GITEA_REPO_URL }}" ` + -Branch "${{ github.ref_name }}" ` + -Commit "${{ github.sha }}" +``` + +--- + +### Step 4 — Invoke-CIJob.ps1: Clone VM + +``` +New-BuildVM.ps1 + vmrun.exe -T ws clone linked -snapshot "BaseClean" +``` + +- Generates unique clone name: `Clone_{JobId}_{yyyyMMdd_HHmmss}` +- Creates clone directory under `F:\CI\BuildVMs\` +- **Linked clone** creation takes ~5–15 seconds +- The template snapshot "BaseClean" is **not modified** +- Returns the full path to the new `.vmx` file + +--- + +### Step 5 — Start VM + +``` +vmrun.exe -T ws start nogui +``` + +- Starts the cloned VM in headless (no GUI) mode +- VM boots from the "BaseClean" state: clean OS, build tools installed, WinRM enabled +- Boot takes ~20–40 seconds + +--- + +### Step 6 — Wait for VM Readiness + +``` +Wait-VMReady.ps1 -VMPath -IPAddress -TimeoutSeconds 300 +``` + +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): + +``` +Phase 1: vmrun getState → must return "running" +Phase 2: Test-Connection (ICMP ping) → must succeed +Phase 3: Test-WSMan → WinRM listener must respond +``` + +- Total wait is typically **30–90 seconds** after `vmrun start` +- If timeout exceeded, VM is destroyed and job fails with a clear error message +- On success, IP and credentials are ready for PSSession + +--- + +### Step 7 — Remote Build Execution + +``` +Invoke-RemoteBuild.ps1 + -IPAddress # from vmrun getGuestIPAddress + -Credential (from Windows Credential Manager) + -BuildCommand 'python build_plugin.py --final --dist-dir dist' + -GuestArtifactSource 'dist' +``` + +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) +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) + b. Raccoglie artifact in C:\CI\build\dist\ + c. Cleanup build dir (--final) +4. Compress-Archive C:\CI\build\$GuestArtifactSource C:\CI\output\artifacts.zip +5. Cattura exit code; se non-zero il job fallisce con errore chiaro +6. Remove-PSSession +``` + +- Output standard e stderr dalla build sono streamati all'host e catturati da act_runner per i log Gitea Actions +- Se la build fallisce (exit code ≠ 0), il controllo passa al blocco `finally` + +--- + +### Step 8 — Collect Artifacts + +``` +Get-BuildArtifacts.ps1 + -IPAddress + -Credential (same as above) + -GuestArtifactPath C:\CI\output\artifacts.zip + -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 + +--- + +### Step 9 — Destroy VM + +``` +Remove-BuildVM.ps1 -VMPath +``` + +Sequence: + +``` +1. vmrun stop soft (graceful shutdown attempt) +2. Wait 5 seconds +3. vmrun getState → if still "running": + vmrun stop hard (force off) +4. vmrun deleteVM (removes VMX + VMDK delta files) +5. Remove-Item -Recurse (removes any leftover files) +``` + +- VM is gone from disk within ~5–10 seconds +- No state, no temp files, no credentials remain in the clone +- Template VM and "BaseClean" snapshot are unaffected + +> **Note:** This step runs in a `finally` block inside `Invoke-CIJob.ps1`, so it +> executes even if Steps 6–8 failed. A build VM is **never** left running. + +--- + +### Step 10 — Report Status & Upload Artifacts + +``` +act_runner ─► Gitea Actions API (job status: success / failure) +act_runner ─► Gitea artifact store (uploads F:\CI\Artifacts\{JobId}\*) +``` + +- act_runner reads the exit code from `Invoke-CIJob.ps1` +- Reports `success` or `failure` to Gitea +- Uploads artifacts using `actions/upload-artifact` step in the workflow YAML +- Build log (stdout from all steps) is stored in Gitea Actions UI +- Commit/PR status badge is updated + +--- + +## Parallel Build Flow + +When multiple pushes happen concurrently (or multiple PRs are open): + +``` +act_runner (capacity: 4) + ├── Job #1 ──► Clone_Job_001 (192.168.79.x) → build → destroy + ├── Job #2 ──► Clone_Job_002 (192.168.79.x) → build → destroy + ├── Job #3 ──► Clone_Job_003 (192.168.79.x) → build → destroy + └── Job #4 ──► Clone_Job_004 (192.168.79.x) → build → destroy + (Job #5 waits in Gitea queue) +``` + +Note: each clone gets a dynamic IP from VMware NAT DHCP (192.168.79.0/24). IP is discovered +automatically via `vmrun getGuestIPAddress` — no static assignment required. + +- Each job creates its own independently named clone +- Clones share the parent "BaseClean" snapshot (read-only CoW) +- No filesystem or process overlap between concurrent builds +- act_runner enforces the `capacity` limit + +--- + +## Failure Scenarios + +| Failure Point | Behavior | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| VM clone fails | `Invoke-CIJob.ps1` throws; no VM to destroy; job fails immediately | +| VM never reaches ready state (timeout) | `Wait-VMReady.ps1` throws; `finally` destroys the (running) VM | +| Build fails inside VM | `Invoke-RemoteBuild.ps1` throws; `finally` collects partial artifacts (if any), destroys VM | +| Artifact collection fails | Job marked failed; VM is still destroyed in `finally` | +| Host machine loses power mid-job | `act_runner` resumes on reboot, marks in-flight job as failed; orphan VMs must be cleaned up manually (see TODO.md) | + +--- + +## Timing Summary (typical build) + +| Phase | Typical Duration | +| ---------------------------- | -------------------- | +| Gitea → act_runner poll | 5–10 s | +| Linked clone creation | 5–15 s | +| VM boot | 20–40 s | +| WinRM readiness | 10–30 s (after boot) | +| git clone (from local Gitea) | 2–10 s | +| dotnet restore + build | project-dependent | +| Artifact collection | 2–10 s | +| VM destruction | 5–10 s | +| **Overhead total** | **~60–120 s** | diff --git a/docs/OPTIMIZATION.md b/docs/OPTIMIZATION.md new file mode 100644 index 0000000..727f833 --- /dev/null +++ b/docs/OPTIMIZATION.md @@ -0,0 +1,197 @@ +# Optimization Strategies + +## 1. Linked Clone Disk Layout + +**Goal:** Minimize clone creation time and I/O contention between concurrent VMs. + +### Recommended NVMe Partition Layout + +``` +NVMe SSD (e.g. 2TB) +├── C:\ — Windows host OS, VMware Workstation installation +├── F:\CI\ +│ ├── Templates\ — Template VM VMX + base VMDK (parent snapshot) +│ ├── BuildVMs\ — Ephemeral linked clone VMs (delta VMDKs) +│ ├── Artifacts\ — Collected build artifacts (per job) +│ ├── Cache\ — NuGet / npm cache (see §4) +│ └── RunnerWork\ — act_runner workspace (checkout, step scripts) +``` + +**Why separate directories matter:** +- Template VMDK reads (CoW base) and clone delta writes happen simultaneously +- Keeping them on the same fast NVMe avoids I/O stalls; a separate spinning disk for + the clone directory would bottleneck clone creation +- Artifacts and cache dirs have sequential I/O patterns; they can share space + +--- + +## 2. Snapshot Strategy + +### Tiered Snapshot Model + +``` +Tier 0: Base OS Install (Windows only, no tools) + └── Snapshot: "OSBase" — rarely updated (yearly or on major Windows updates) + +Tier 1: Build Toolchain + └── Snapshot: "BaseClean" ← CLONE SOURCE (weekly refresh) + Includes: VS Build Tools 2022, .NET SDK, Git, WinRM config + Requirement: ALL linked clones must reference this exact snapshot +``` + +### Refresh Schedule + +| Snapshot | Refresh Frequency | Trigger | +|----------|------------------|-------| +| `OSBase` | Quarterly | Windows cumulative update | +| `BaseClean` | Weekly/Monthly | .NET SDK patch, security update, VS update | + +> **Note:** Windows Server 2025 KMS lease = 180 giorni. Prima della scadenza: +> boot template su VMnet8 (NAT) → `slmgr /ato` → spegni → nuovo snapshot `BaseClean`. + +--- + +## 3. Parallel Build Capacity + +### RAM Budget (i9-10900X · 64 GB) + +| Component | RAM Usage | +|-----------|----------| +| Windows host OS | ~4 GB | +| VMware Workstation | ~0.5 GB | +| Gitea server | ~0.5–1 GB | +| act_runner service | ~100 MB | +| Each build VM (idle) | ~2–3 GB | +| Each build VM (active MSBuild/Python) | ~6–8 GB | +| **Headroom target (20%)** | ~13 GB | + +``` +Available for VMs: 64 - 4 - 0.5 - 1 - 0.1 - 13 = ~45 GB +Max VMs at peak: 45 / 8 = ~5.6 → safe limit = 4 +``` + +**Recommendation:** `capacity: 4` in `config.yaml`. + +### CPU Budget (i9-10900X: 10 cores / 20 threads) + +- Template VM: 4 vCPU configurati +- Con 4 VM parallele: 4 × 4 = 16 thread, lascia 4 per host OS / Gitea / runner +- `build_plugin.py` usa `get_optimal_thread_count()` che rileva i core della VM e divide per il numero di build parallele (fix TRK0002) + +### VM Configuration (template VMX) + +```ini +numvcpus = "4" +cpuid.coresPerSocket = "2" +memsize = "6144" # 6 GB RAM per VM +scsi0.virtualDev = "pvscsi" +ethernet0.virtualDev = "vmxnet3" +``` + +--- + +## 4. NuGet / Package Cache on Host + +**Problem:** Each build VM does a fresh `dotnet restore`, re-downloading NuGet packages every time. + +**Solution:** Map a host-side NuGet cache directory as a shared folder into each VM. + +### Setup (host side) + +``` +F:\CI\Cache\NuGet\ ← shared folder on host +``` + +### VMware Shared Folder Configuration (in template VMX) + +Add to `WinBuild.vmx` before taking the `BaseClean` snapshot: +```ini +sharedFolder0.present = "TRUE" +sharedFolder0.enabled = "TRUE" +sharedFolder0.readAccess = "TRUE" +sharedFolder0.writeAccess = "TRUE" +sharedFolder0.hostPath = "F:\\CI\\Cache\\NuGet" +sharedFolder0.guestName = "nuget-cache" +sharedFolder.maxNum = "1" +``` + +Inside the VM, the shared folder appears as `\\vmware-host\Shared Folders\nuget-cache`. + +### NuGet Cache Redirect (in `Invoke-RemoteBuild.ps1`) + +Add to the remote build ScriptBlock: +```powershell +$env:NUGET_PACKAGES = '\\vmware-host\Shared Folders\nuget-cache' +dotnet restore --packages $env:NUGET_PACKAGES +``` + +**Result:** First build per package downloads once; subsequent builds read from the host cache. +Cache is shared across all concurrent VMs (NuGet packages are safe for concurrent reads). + +--- + +## 5. Clone Pre-Warming + +For latency-sensitive pipelines, keep N pre-warmed clones ready in "booted and idle" state. + +**Tradeoff:** Saves ~45–90 seconds of startup per job, but clones remain running (consuming RAM) until used. + +**Implementation sketch:** + +```powershell +# Pre-warmer job (separate scheduled task on host) +# Runs between CI jobs to maintain a pool of warm VMs +$poolSize = 2 # Keep 2 VMs warm at all times + +# Check current running clones +$runningClones = Get-ChildItem F:\CI\WarmPool\ -Filter *.vmx +if ($runningClones.Count -lt $poolSize) { + # Create and start new clone in warm pool + $vmx = .\New-BuildVM.ps1 -TemplatePath $template -CloneBaseDir F:\CI\WarmPool -JobId "warm-$(Get-Random)" + vmrun -T ws start $vmx nogui +} +``` + +> **Note:** Pre-warming is an advanced optimization. Start without it and add +> only if CI overhead (startup time) is a demonstrated bottleneck. + +--- + +## 6. Artifact Storage Management + +Build artifacts accumulate quickly. Automate cleanup: + +```powershell +# Scheduled task: run daily +# Remove artifact dirs older than 30 days +Get-ChildItem 'F:\CI\Artifacts' -Directory | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-30) } | + Remove-Item -Recurse -Force + +# Remove orphaned clone directories (VMs that were not properly deleted) +Get-ChildItem 'F:\CI\BuildVMs' -Directory | + Where-Object { $_.LastWriteTime -lt (Get-Date).AddHours(-4) } | + ForEach-Object { + $vmx = Get-ChildItem $_.FullName -Filter *.vmx | Select-Object -First 1 + if ($vmx) { + vmrun -T ws stop $vmx.FullName hard 2>$null + } + Remove-Item $_.FullName -Recurse -Force + } +``` + +--- + +## 7. NUMA & CPU Affinity (Advanced) + +The i9-10900X is a single NUMA node CPU (no NUMA effects). CPU pinning is not needed. + +However, you can set CPU affinity per VM group to reduce vCPU migration overhead: + +```powershell +# Optional: pin act_runner process to cores 0-3 (leave build VMs on 4-19) +$runnerPid = (Get-Process act_runner).Id +Start-Process -FilePath 'cmd' -ArgumentList "/c start /affinity 0xF /b /wait powershell" -NoNewWindow +``` + +For most workloads, Windows scheduler handles this well — skip affinity unless profiling shows issues. diff --git a/docs/Setup-GiteaSSH.md b/docs/Setup-GiteaSSH.md new file mode 100644 index 0000000..34f08fc --- /dev/null +++ b/docs/Setup-GiteaSSH.md @@ -0,0 +1,101 @@ +# Configurazione accesso SSH a Gitea + +**Target**: clone da `git@gitea.emulab.it:Simone/nsis-plugin-nsinnounp.git` +**Gitea**: `http://10.10.20.11:3100` / `https://gitea.emulab.it` + +--- + +## 1. Genera la chiave SSH sull'host Windows + +```powershell +# Da PowerShell normale (non admin necessario) +ssh-keygen -t ed25519 -C "ci-build@WS1-W11" -f "$env:USERPROFILE\.ssh\id_ci_gitea" +``` + +- Passphrase: **lascia vuota** (il CI gira non-interattivo) +- Produce due file: + - `~\.ssh\id_ci_gitea` — chiave privata (non condividere mai) + - `~\.ssh\id_ci_gitea.pub` — chiave pubblica da aggiungere a Gitea + +--- + +## 2. Aggiungi la chiave pubblica a Gitea + +1. Apri `http://10.10.20.11:3100` → Login come **Simone** +2. Menu utente (in alto a destra) → **Settings** → **SSH / GPG Keys** +3. Clicca **Add Key** +4. Incolla il contenuto di `~\.ssh\id_ci_gitea.pub`: + +```powershell +Get-Content "$env:USERPROFILE\.ssh\id_ci_gitea.pub" | clip +``` + +5. **Key Name**: `WS1-CI-Host` +6. Salva → **Add Key** + +--- + +## 3. Configura SSH client sull'host + +> **Attenzione**: `10.10.20.11` è già usato nel file `~\.ssh\config` con `User root` (entry `HomeAssistant`). +> Non usare `Host 10.10.20.11` direttamente — usare un alias dedicato `gitea-ci`. + +Aggiungi in fondo a `~\.ssh\config`: + +```powershell +$entry = @" + +Host gitea-ci + HostName 10.10.20.11 + Port 2222 + User git + IdentityFile $env:USERPROFILE\.ssh\id_ci_gitea + IdentitiesOnly yes +"@ +Add-Content "$env:USERPROFILE\.ssh\config" $entry +``` + +--- + +## 4. Verifica connessione + +```powershell +ssh -T gitea-ci +``` + +Risposta attesa: +``` +Hi there, Simone! You've successfully authenticated with the key named ci-build@WS1-W11, but Gitea does not provide shell access. +``` + +> **Nota**: exit code 1 è **normale** — Gitea non fornisce shell, ma l'autenticazione è avvenuta. + +--- + +## 5. ~~Trova la porta SSH di Gitea~~ + +**Porta confermata: 2222** (verificata con `Test-NetConnection -ComputerName 10.10.20.11 -Port 2222`). + +--- + +## 6. URL SSH da usare in Invoke-CIJob.ps1 + +Usare l'alias `gitea-ci` definito in `~\.ssh\config`: + +```powershell +-RepoUrl 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git' +``` + +Oppure con IP/porta espliciti (equivalente): + +```powershell +-RepoUrl 'ssh://git@10.10.20.11:2222/Simone/nsis-plugin-nsinnounp.git' +``` + +--- + +## 7. Aggiungi host a known_hosts (evita prompt interattivo) + +```powershell +ssh-keyscan -p 2222 10.10.20.11 >> "$env:USERPROFILE\.ssh\known_hosts" +``` diff --git a/gitea/workflow-example.yml b/gitea/workflow-example.yml new file mode 100644 index 0000000..a371270 --- /dev/null +++ b/gitea/workflow-example.yml @@ -0,0 +1,124 @@ +# Gitea Actions workflow: .NET build with ephemeral VMware VM +# Copy this file to: .gitea/workflows/build.yml in your repository. +# +# This workflow triggers on push to main/develop and on pull requests. +# It routes the job to the act_runner with label "windows-build", +# which creates an isolated VM per build. + +name: Build (.NET / MSBuild) + +on: + push: + branches: + - main + - develop + - 'release/**' + pull_request: + branches: + - main + - develop + +jobs: + build: + # Must match the runner label defined in runner/config.yaml + runs-on: windows-build + + # Job-level timeout (should be less than the runner's timeout setting) + timeout-minutes: 90 + + env: + # Override these per-repository or in Gitea's repository secrets + # GITEA_CI_TEMPLATE_PATH is injected by runner/config.yaml global envs + # GITEA_CI_CLONE_BASE_DIR is injected by runner/config.yaml global envs + + # IP address for this build VM. + # For parallel builds, each concurrent job needs a unique IP. + # Assign via Gitea matrix or a simple offset scheme. + # Example: use a fixed IP per runner slot, mapped by capacity index. + # For single concurrent builds: use one fixed IP. + BUILD_VM_IP: "192.168.11.101" + + # Gitea Credential Manager target (set in runner/config.yaml) + GUEST_CRED_TARGET: "BuildVMGuest" + + steps: + # Step 1: Check out the repository on the RUNNER HOST + # (This gives the runner access to the scripts/ directory) + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # Step 2: Invoke the orchestrator script + # This script handles the entire VM lifecycle: + # clone → start → wait ready → build → collect artifacts → destroy + - name: Build in ephemeral VM + shell: pwsh + 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' + + & "$ciScriptsDir\Invoke-CIJob.ps1" ` + -JobId "${{ github.run_id }}-${{ github.run_attempt }}" ` + -RepoUrl "${{ github.server_url }}/${{ github.repository }}.git" ` + -Branch "${{ github.ref_name }}" ` + -Commit "${{ github.sha }}" ` + -Configuration "Release" ` + -VMIPAddress "$env:BUILD_VM_IP" ` + -GuestCredentialTarget "$env:GUEST_CRED_TARGET" + + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } + + # Step 3: Upload artifacts to Gitea + # Artifacts were collected to F:\CI\Artifacts\{JobId}\ by Invoke-CIJob.ps1 + - name: Upload build artifacts + if: success() + uses: actions/upload-artifact@v4 + with: + name: build-${{ github.sha }} + path: F:\CI\Artifacts\${{ github.run_id }}-${{ github.run_attempt }}\ + retention-days: 14 + if-no-files-found: error + + # Step 4: Upload artifacts even on failure (for diagnostics) + - name: Upload diagnostic logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: build-logs-${{ github.sha }} + path: F:\CI\Artifacts\${{ github.run_id }}-${{ github.run_attempt }}\*.log + retention-days: 3 + if-no-files-found: ignore + +--- +# PARALLEL BUILD VARIANT (using matrix strategy) +# Uncomment and adapt if you need to build multiple configurations in parallel. +# +# jobs: +# build: +# runs-on: windows-build +# strategy: +# matrix: +# include: +# - config: Release +# vm_ip: "192.168.11.101" +# - config: Debug +# vm_ip: "192.168.11.102" +# fail-fast: false +# +# steps: +# - uses: actions/checkout@v4 +# - name: Build (${{ matrix.config }}) +# shell: pwsh +# run: | +# & scripts\Invoke-CIJob.ps1 ` +# -JobId "${{ github.run_id }}-${{ matrix.config }}" ` +# -RepoUrl "${{ github.server_url }}/${{ github.repository }}.git" ` +# -Branch "${{ github.ref_name }}" ` +# -Configuration "${{ matrix.config }}" ` +# -VMIPAddress "${{ matrix.vm_ip }}" diff --git a/gitea/workflows/build-nsis.yml b/gitea/workflows/build-nsis.yml new file mode 100644 index 0000000..637f1e1 --- /dev/null +++ b/gitea/workflows/build-nsis.yml @@ -0,0 +1,46 @@ +# Build (Local CI) — pipeline per nsis-plugin-nsinnounp +# +# Eseguito dal runner locale windows-build su WS1-W11. +# L'orchestratore (Invoke-CIJob.ps1) gira sull'host e: +# 1. Clona il repo sull'host (con submoduli) +# 2. Crea una VM efimera da snapshot BaseClean +# 3. Copia il sorgente nella VM via WinRM (zip transfer) +# 4. Esegue python build_plugin.py nella VM +# 5. Raccoglie artifacts.zip sull'host +# 6. Distrugge la VM +# +# Trigger: push su tag v*, oppure run manuale + +name: Build (Local CI) + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: windows-build + + steps: + - name: Run CI Job + shell: pwsh + run: | + & 'N:\Code\Workspace\Local-CI-CD-System\scripts\Invoke-CIJob.ps1' ` + -JobId '${{ github.run_id }}' ` + -RepoUrl 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git' ` + -Branch '${{ github.ref_name }}' ` + -Commit '${{ github.sha }}' ` + -TemplatePath $env:GITEA_CI_TEMPLATE_PATH ` + -Submodules ` + -BuildCommand 'python build_plugin.py --final --dist-dir dist' ` + -GuestArtifactSource 'dist' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: nsis-plugin-nsinnounp-${{ github.ref_name }} + path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip + if-no-files-found: error diff --git a/runner/Install-Runner.ps1 b/runner/Install-Runner.ps1 new file mode 100644 index 0000000..7c39faf --- /dev/null +++ b/runner/Install-Runner.ps1 @@ -0,0 +1,201 @@ +#Requires -RunAsAdministrator +#Requires -Version 5.1 +<# +.SYNOPSIS + Downloads, registers, and installs act_runner as a Windows service. + +.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. + 3. Installs it as a Windows service that auto-starts with the system. + + Prerequisites: + - Run as Administrator + - NSSM (Non-Sucking Service Manager) must be installed and in PATH, + OR use the built-in sc.exe method (creates a wrapper via PowerShell). + - The Gitea instance must be reachable from this host. + +.PARAMETER GiteaURL + Base URL of your Gitea instance. Example: http://192.168.1.50:3000 + +.PARAMETER RegistrationToken + Runner registration token from Gitea: + Admin Panel → Settings → Actions → Runners → "Create Runner Token" + +.PARAMETER InstallDir + Directory where act_runner will be installed. + Default: F:\CI\act_runner + +.PARAMETER RunnerName + Display name for this runner in the Gitea UI. + Default: local-windows-runner + +.PARAMETER ActRunnerVersion + Version tag to download. Default: latest (resolves via GitHub API). + Example: v0.2.11 + +.PARAMETER ServiceName + Windows service name. Default: act_runner + +.EXAMPLE + .\Install-Runner.ps1 ` + -GiteaURL "http://192.168.1.50:3000" ` + -RegistrationToken "abc123xyz" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $GiteaURL, + + [Parameter(Mandatory)] + [string] $RegistrationToken, + + [string] $InstallDir = 'F:\CI\act_runner', + + [string] $RunnerName = 'local-windows-runner', + + [string] $ActRunnerVersion = 'latest', + + [string] $ServiceName = 'act_runner' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ── Resolve download URL ────────────────────────────────────────────────────── +if ($ActRunnerVersion -eq 'latest') { + Write-Host "[Install-Runner] Resolving latest act_runner version from Gitea releases..." + try { + $releaseInfo = Invoke-RestMethod -Uri 'https://dl.gitea.com/act_runner/version.json' -UseBasicParsing + $ActRunnerVersion = $releaseInfo.latest + } + catch { + Write-Warning "Could not resolve latest version automatically. Set -ActRunnerVersion explicitly." + Write-Warning "Find releases at: https://gitea.com/gitea/act_runner/releases" + throw + } +} + +$downloadUrl = "https://dl.gitea.com/act_runner/${ActRunnerVersion}/act_runner-${ActRunnerVersion}-windows-amd64.exe" +$binaryPath = Join-Path $InstallDir 'act_runner.exe' +$configPath = Join-Path $InstallDir 'config.yaml' + +Write-Host "[Install-Runner] Version : $ActRunnerVersion" +Write-Host "[Install-Runner] Install dir: $InstallDir" +Write-Host "[Install-Runner] Service : $ServiceName" + +# ── Create install directory ────────────────────────────────────────────────── +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Write-Host "[Install-Runner] Created directory: $InstallDir" +} + +# ── Download act_runner binary ──────────────────────────────────────────────── +if (Test-Path $binaryPath) { + Write-Host "[Install-Runner] act_runner.exe already exists — skipping download." + Write-Host " Delete $binaryPath to force re-download." +} +else { + Write-Host "[Install-Runner] Downloading from: $downloadUrl" + Invoke-WebRequest -Uri $downloadUrl -OutFile $binaryPath -UseBasicParsing + Write-Host "[Install-Runner] Downloaded: $binaryPath" +} + +# ── Copy config.yaml if not already present ─────────────────────────────────── +$repoConfigPath = Join-Path $PSScriptRoot 'config.yaml' +if (-not (Test-Path $configPath)) { + if (Test-Path $repoConfigPath) { + Copy-Item $repoConfigPath $configPath + Write-Host "[Install-Runner] Copied config.yaml from repo to $configPath" + Write-Host " Review and update paths in $configPath before proceeding." + } + else { + Write-Warning "[Install-Runner] config.yaml not found at $repoConfigPath" + Write-Warning " Generating default config and registering without custom config." + # Generate default config + Push-Location $InstallDir + & $binaryPath generate-config | Out-File $configPath -Encoding UTF8 + Pop-Location + } +} +else { + Write-Host "[Install-Runner] Using existing config.yaml: $configPath" +} + +# ── Register runner with Gitea ──────────────────────────────────────────────── +$stateFile = Join-Path $InstallDir '.runner' +if (Test-Path $stateFile) { + Write-Host "[Install-Runner] Runner already registered (.runner state file exists)." + Write-Host " Delete $stateFile to re-register." +} +else { + Write-Host "[Install-Runner] Registering runner with Gitea at $GiteaURL..." + Push-Location $InstallDir + try { + & $binaryPath register ` + --no-interactive ` + --instance $GiteaURL ` + --token $RegistrationToken ` + --name $RunnerName ` + --labels 'windows-build:host,dotnet:host,msbuild:host' ` + --config $configPath + if ($LASTEXITCODE -ne 0) { + throw "act_runner register exited with code $LASTEXITCODE" + } + } + finally { + Pop-Location + } + Write-Host "[Install-Runner] Runner registered successfully." +} + +# ── Install as Windows service ──────────────────────────────────────────────── +$existingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue + +if ($existingService) { + Write-Host "[Install-Runner] Service '$ServiceName' already exists." + Write-Host " Stopping and updating..." + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue +} + +# Build service command (act_runner daemon with explicit config) +$serviceCmd = "`"$binaryPath`" daemon --config `"$configPath`"" + +if ($existingService) { + # Update existing service binary path + sc.exe config $ServiceName binpath= $serviceCmd | Out-Null +} +else { + # Create new service + New-Service ` + -Name $ServiceName ` + -BinaryPathName $serviceCmd ` + -DisplayName 'Gitea act_runner (CI/CD)' ` + -Description 'Gitea Actions runner for ephemeral VMware build VMs' ` + -StartupType Automatic | Out-Null + Write-Host "[Install-Runner] Service created: $ServiceName" +} + +# Configure service recovery: restart on failure +sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/10000/restart/30000 | Out-Null + +# Start the service +Start-Service -Name $ServiceName +Write-Host "[Install-Runner] Service '$ServiceName' started." + +# ── Verify ──────────────────────────────────────────────────────────────────── +Start-Sleep -Seconds 3 +$svc = Get-Service -Name $ServiceName +if ($svc.Status -eq 'Running') { + Write-Host "[Install-Runner] act_runner is running as a Windows service." + Write-Host "[Install-Runner] Check Gitea Admin → Settings → Actions → Runners to confirm it appears Online." +} +else { + Write-Warning "[Install-Runner] Service status: $($svc.Status). Check Windows Event Log for errors." +} + +Write-Host "" +Write-Host "Installation complete." +Write-Host " Binary : $binaryPath" +Write-Host " Config : $configPath" +Write-Host " Service : $ServiceName (startup: Automatic)" diff --git a/runner/config.yaml b/runner/config.yaml new file mode 100644 index 0000000..e2e213f --- /dev/null +++ b/runner/config.yaml @@ -0,0 +1,82 @@ +# act_runner configuration for Local CI/CD System +# Documentation: https://gitea.com/gitea/act_runner + +log: + # Log level: trace, debug, info, warn, error, fatal + level: info + # Log format: text or json + format: text + +runner: + # Path to the runner state file (stores runner registration info) + file: .runner + + # Maximum number of concurrent jobs this runner will accept. + # Each job spawns one ephemeral VM. Budget: 64GB RAM / ~8GB per VM = 8 max. + # Set conservatively to 4 until you benchmark actual memory usage. + capacity: 4 + + # 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" + # 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" + + # Optional: load additional env vars from a file (one KEY=VALUE per line) + # env_file: "F:\\CI\\runner.env" + + # Maximum duration for a single job before it is killed. + # Set this higher than your longest expected build. + timeout: 2h + + # Labels this runner advertises to Gitea. + # Workflows using "runs-on: windows-build" will be routed to this runner. + labels: + - "windows-build:host" + - "dotnet:host" + - "msbuild:host" + + # Uncomment to ignore specific errors + # insecure: false + +cache: + # Enable the built-in cache server (used by actions/cache steps) + enabled: true + # Cache storage directory on the host + dir: "F:\\CI\\Cache" + # External cache server - leave empty to use built-in + host: "" + port: 0 + +container: + # IMPORTANT: must be false on this runner. + # All builds run in VMware VMs, not Docker containers. + enabled: false + + # These settings are ignored when container.enabled is false, + # but kept for documentation purposes. + # network: "" + # privileged: false + # options: "" + # workdir_parent: "" + +host: + # Working directory on the host for act_runner's own workspace files. + # Each job gets a subdirectory here for checkout and step scripts. + workdir_parent: "F:\\CI\\RunnerWork" + +artifact: + # Gitea artifact proxy settings + # Artifacts uploaded by steps (actions/upload-artifact) are stored via Gitea. + # The runner itself handles local artifact paths via Invoke-CIJob.ps1. + enabled: true + dir: "F:\\CI\\Artifacts" + # Retention in days for artifacts stored by act_runner itself + # (distinct from the Gitea server's own artifact retention setting) + retention: + days: 7 diff --git a/scripts/Get-BuildArtifacts.ps1 b/scripts/Get-BuildArtifacts.ps1 new file mode 100644 index 0000000..399e5d7 --- /dev/null +++ b/scripts/Get-BuildArtifacts.ps1 @@ -0,0 +1,122 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Copies build artifacts from an ephemeral VM to the host artifact directory. + +.DESCRIPTION + Opens a WinRM session to the build VM and uses Copy-Item -FromSession + to transfer the artifact archive and optional log files. + Validates that at least one file was successfully transferred. + Throws on failure so the caller's try/finally can clean up the VM. + +.PARAMETER IPAddress + IP of the build VM. + +.PARAMETER Credential + PSCredential for the guest build user. + +.PARAMETER GuestArtifactPath + Full path to the artifact file or directory inside the VM. + Example: C:\CI\output\artifacts.zip + +.PARAMETER HostArtifactDir + Directory on the host where artifacts will be stored. + The directory is created if it does not exist. + Example: F:\CI\Artifacts\run-42 + +.PARAMETER IncludeLogs + If set, also copies *.log files from the guest work directory + (C:\CI\build\*.log) alongside the artifact archive. + +.EXAMPLE + $cred = Get-Credential + .\Get-BuildArtifacts.ps1 ` + -IPAddress "192.168.11.101" ` + -Credential $cred ` + -GuestArtifactPath "C:\CI\output\artifacts.zip" ` + -HostArtifactDir "F:\CI\Artifacts\run-42" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] + [string] $IPAddress, + + [Parameter(Mandatory)] + [System.Management.Automation.PSCredential] $Credential, + + [Parameter(Mandatory)] + [string] $GuestArtifactPath, + + [Parameter(Mandatory)] + [string] $HostArtifactDir, + + [switch] $IncludeLogs +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# 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 + +Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..." +$session = New-PSSession ` + -ComputerName $IPAddress ` + -Credential $Credential ` + -SessionOption $sessionOptions ` + -ErrorAction Stop + +try { + # ── Verify artifact exists in guest ────────────────────────────────── + $guestExists = Invoke-Command -Session $session -ScriptBlock { + param($path) + Test-Path $path + } -ArgumentList $GuestArtifactPath + + if (-not $guestExists) { + throw "Artifact not found in guest at: $GuestArtifactPath" + } + + # ── Copy main artifact ──────────────────────────────────────────────── + $artifactFileName = Split-Path $GuestArtifactPath -Leaf + $hostDestPath = Join-Path $HostArtifactDir $artifactFileName + + Write-Host "[Get-BuildArtifacts] Copying $artifactFileName from guest..." + Copy-Item -Path $GuestArtifactPath -Destination $hostDestPath -FromSession $session -Force + + # ── Copy logs if requested ──────────────────────────────────────────── + if ($IncludeLogs) { + $logFiles = Invoke-Command -Session $session -ScriptBlock { + Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + } + + foreach ($log in $logFiles) { + $logDest = Join-Path $HostArtifactDir (Split-Path $log -Leaf) + Write-Host "[Get-BuildArtifacts] Copying log: $(Split-Path $log -Leaf)" + Copy-Item -Path $log -Destination $logDest -FromSession $session -Force + } + } + + # ── Validate at least the main artifact landed ──────────────────────── + if (-not (Test-Path $hostDestPath)) { + 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) { + throw "Artifact file exists but is empty: $hostDestPath" + } + + Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)" + return $hostDestPath +} +finally { + Remove-PSSession $session -ErrorAction SilentlyContinue +} diff --git a/scripts/Invoke-CIJob.ps1 b/scripts/Invoke-CIJob.ps1 new file mode 100644 index 0000000..7497219 --- /dev/null +++ b/scripts/Invoke-CIJob.ps1 @@ -0,0 +1,281 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Main CI orchestrator: creates an ephemeral VM, runs a build, collects + artifacts, and destroys the VM — in a guaranteed try/finally pattern. + +.DESCRIPTION + This script is called by act_runner as the primary build step. + It coordinates all sub-scripts: + 1. New-BuildVM.ps1 — linked clone from template + 2. vmrun start — start the clone + 3. Wait-VMReady.ps1 — poll until WinRM is ready + 4. Invoke-RemoteBuild.ps1 — execute build inside VM + 5. Get-BuildArtifacts.ps1 — copy artifacts to host + 6. Remove-BuildVM.ps1 — destroy VM (always runs via finally) + + Exit codes: + 0 = success + 1 = build or orchestration failure (VM destroyed, artifacts may be partial) + +.PARAMETER JobId + Unique job identifier (use ${{ github.run_id }} from Gitea Actions). + +.PARAMETER RepoUrl + Git URL of the repository to build (reachable from the build VM). + +.PARAMETER Branch + Branch to build. + +.PARAMETER Commit + Optional specific commit SHA to check out. + +.PARAMETER Configuration + Build configuration. Default: Release + +.PARAMETER TemplatePath + Full path to the template VM .vmx file. + Can also be set via env var GITEA_CI_TEMPLATE_PATH. + +.PARAMETER SnapshotName + Template snapshot to clone from. Default: BaseClean + +.PARAMETER CloneBaseDir + Base directory for clone VMs. + Can also be set via env var GITEA_CI_CLONE_BASE_DIR. + Default: F:\CI\BuildVMs + +.PARAMETER ArtifactBaseDir + Host directory where artifacts are stored per job. + Default: F:\CI\Artifacts + +.PARAMETER VMIPAddress + IP address to assign/expect for the build VM. + Must be unique per concurrent build. + Can be passed explicitly or derived from a registry/file-based IP allocator. + +.PARAMETER GuestCredentialTarget + Target name in Windows Credential Manager for guest credentials. + Default: BuildVMGuest + +.PARAMETER VmrunPath + Path to vmrun.exe. + Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe + +.EXAMPLE + # Called from Gitea Actions workflow: + .\scripts\Invoke-CIJob.ps1 ` + -JobId "${{ github.run_id }}" ` + -RepoUrl "http://gitea.local/org/myrepo.git" ` + -Branch "${{ github.ref_name }}" ` + -Commit "${{ github.sha }}" ` + -VMIPAddress "192.168.11.101" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $JobId, + + [Parameter(Mandatory)] + [string] $RepoUrl, + + [Parameter(Mandatory)] + [string] $Branch, + + [string] $Commit = '', + + [string] $Configuration = 'Release', + + # Pass -Submodules to clone with --recurse-submodules + [switch] $Submodules, + + # Custom build command to run inside the VM (empty = dotnet build) + [string] $BuildCommand = '', + + # Artifact source pattern relative to GuestWorkDir (used with BuildCommand) + [string] $GuestArtifactSource = 'dist', + + [string] $TemplatePath = $env:GITEA_CI_TEMPLATE_PATH, + + [string] $SnapshotName = '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}$')] + [string] $VMIPAddress = '', # if empty, auto-detected via vmrun getGuestIPAddress + + [string] $GuestCredentialTarget = 'BuildVMGuest', + + [string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ── Resolve script directory for dot-sourcing sibling scripts ───────────────── +$scriptDir = $PSScriptRoot + +# ── Validate required config ────────────────────────────────────────────────── +if ([string]::IsNullOrWhiteSpace($TemplatePath)) { + Write-Error "TemplatePath is required. Set -TemplatePath or env:GITEA_CI_TEMPLATE_PATH." + exit 1 +} +if (-not (Test-Path $TemplatePath -PathType Leaf)) { + Write-Error "Template VMX not found: $TemplatePath" + exit 1 +} +if (-not (Test-Path $VmrunPath -PathType Leaf)) { + Write-Error "vmrun.exe not found: $VmrunPath" + exit 1 +} + +# ── Load guest credentials from Windows Credential Manager ─────────────────── +# Requires the CredentialManager module: Install-Module CredentialManager +# In a lab setup you can fall back to prompting, but never hardcode credentials. +$credential = $null +try { + $credential = Get-StoredCredential -Target $GuestCredentialTarget -ErrorAction Stop + Write-Host "[Invoke-CIJob] Loaded guest credentials from Credential Manager ($GuestCredentialTarget)." +} +catch { + Write-Warning "[Invoke-CIJob] Could not load credentials from Credential Manager ($GuestCredentialTarget)." + Write-Warning " Using interactive prompt as fallback. Store credentials with:" + Write-Warning " cmdkey /generic:$GuestCredentialTarget /user:DOMAIN\user /pass:password" + $credential = Get-Credential -Message "Enter guest VM credentials for job $JobId" +} + +# ── Setup paths ─────────────────────────────────────────────────────────────── +$jobArtifactDir = Join-Path $ArtifactBaseDir $JobId +$hostCloneDir = Join-Path $env:TEMP "ci-src-$JobId" +$cloneVmxPath = $null +$startTime = Get-Date + +Write-Host "============================================================" +Write-Host "[Invoke-CIJob] Job : $JobId" +Write-Host "[Invoke-CIJob] Repository : $RepoUrl" +Write-Host "[Invoke-CIJob] Branch : $Branch" +if ($Commit) { Write-Host "[Invoke-CIJob] Commit : $Commit" } +Write-Host "[Invoke-CIJob] Build VM IP : $VMIPAddress" +Write-Host "[Invoke-CIJob] Template : $TemplatePath" +Write-Host "[Invoke-CIJob] Artifacts : $jobArtifactDir" +Write-Host "[Invoke-CIJob] Started : $startTime" +Write-Host "============================================================" + +$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. + 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) + if ($Submodules) { $gitArgs += '--recurse-submodules' } + $gitArgs += @($RepoUrl, $hostCloneDir) + $ErrorActionPreference = 'Continue' + $gitOutput = & git @gitArgs 2>&1 | ForEach-Object { "$_" } + $gitExit = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + if ($gitExit -ne 0) { + throw "git clone failed (exit $gitExit):`n$($gitOutput -join "`n")" + } + if ($Commit -ne '') { + $ErrorActionPreference = 'Continue' + $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" + + # ── Phase 2: Clone VM ───────────────────────────────────────────────── + Write-Host "`n[Phase 2/6] Cloning VM..." + $cloneVmxPath = & "$scriptDir\New-BuildVM.ps1" ` + -TemplatePath $TemplatePath ` + -SnapshotName $SnapshotName ` + -CloneBaseDir $CloneBaseDir ` + -JobId $JobId ` + -VmrunPath $VmrunPath + + # ── 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) { + throw "vmrun start failed (exit $LASTEXITCODE): $startOutput" + } + Write-Host "[Invoke-CIJob] VM started." + + # ── Phase 3b: Resolve VM IP if not provided ───────────────────────── + 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" + } + $VMIPAddress = $detectedIP.Trim() + Write-Host "[Invoke-CIJob] Detected VM IP: $VMIPAddress" + } + + # ── 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 + + # ── Phase 5: Remote build (copy source + build inside VM) ───────────── + Write-Host "`n[Phase 5/6] Invoking remote build..." + $remoteBuildParams = @{ + IPAddress = $VMIPAddress + Credential = $credential + HostSourceDir = $hostCloneDir + Configuration = $Configuration + } + if ($BuildCommand -ne '') { $remoteBuildParams['BuildCommand'] = $BuildCommand } + if ($GuestArtifactSource -ne '') { $remoteBuildParams['GuestArtifactSource'] = $GuestArtifactSource } + & "$scriptDir\Invoke-RemoteBuild.ps1" @remoteBuildParams + + # ── Phase 6: Collect artifacts ──────────────────────────────────────── + Write-Host "`n[Phase 6/6] Collecting artifacts..." + & "$scriptDir\Get-BuildArtifacts.ps1" ` + -IPAddress $VMIPAddress ` + -Credential $credential ` + -GuestArtifactPath 'C:\CI\output\artifacts.zip' ` + -HostArtifactDir $jobArtifactDir ` + -IncludeLogs + + $elapsed = (Get-Date) - $startTime + Write-Host "`n============================================================" + Write-Host "[Invoke-CIJob] SUCCESS — Job $JobId completed in $($elapsed.ToString('mm\:ss'))" + Write-Host "[Invoke-CIJob] Artifacts: $jobArtifactDir" + Write-Host "============================================================" +} +catch { + $exitCode = 1 + $elapsed = (Get-Date) - $startTime + Write-Host "`n============================================================" + Write-Host "[Invoke-CIJob] FAILURE — Job $JobId failed after $($elapsed.ToString('mm\:ss'))" + Write-Host "Error: $_" + Write-Host "============================================================" +} +finally { + # ── Always destroy the VM ───────────────────────────────────────────── + if ($null -ne $cloneVmxPath -and (Test-Path $cloneVmxPath -PathType Leaf)) { + Write-Host "`n[Cleanup] Destroying ephemeral VM..." + & "$scriptDir\Remove-BuildVM.ps1" -VMPath $cloneVmxPath -VmrunPath $VmrunPath + } + 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) { + Write-Host "[Cleanup] Removing host source clone ($hostCloneDir)..." + Remove-Item $hostCloneDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +exit $exitCode diff --git a/scripts/Invoke-RemoteBuild.ps1 b/scripts/Invoke-RemoteBuild.ps1 new file mode 100644 index 0000000..6a44c29 --- /dev/null +++ b/scripts/Invoke-RemoteBuild.ps1 @@ -0,0 +1,202 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Copies pre-cloned source from host into a build VM and runs dotnet build. + +.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 + + The build VM does NOT need internet access or git. All network I/O happens + on the host before this script is called. + +.PARAMETER IPAddress + IP of the build VM (e.g. 192.168.11.101). + +.PARAMETER Credential + PSCredential for the build user inside the VM. + Retrieve from Windows Credential Manager in production: + $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. + Example: C:\Users\user\AppData\Local\Temp\ci-src-run-42 + +.PARAMETER Configuration + MSBuild/dotnet build configuration. Default: Release + +.PARAMETER GuestWorkDir + Working directory inside the VM where source will be placed. + Default: C:\CI\build + +.PARAMETER GuestArtifactZip + Full path inside the VM where the artifact archive will be written. + Default: C:\CI\output\artifacts.zip + +.EXAMPLE + $cred = Get-Credential + .\Invoke-RemoteBuild.ps1 ` + -IPAddress "192.168.11.101" ` + -Credential $cred ` + -HostSourceDir "C:\Temp\ci-src-run-42" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] + [string] $IPAddress, + + [Parameter(Mandatory)] + [System.Management.Automation.PSCredential] $Credential, + + [Parameter(Mandatory)] + [ValidateScript({ Test-Path $_ -PathType Container })] + [string] $HostSourceDir, + + [string] $Configuration = 'Release', + + # Custom build command to run inside the VM (working dir = GuestWorkDir). + # When empty, defaults to: dotnet restore + dotnet build --configuration $Configuration + # Example: 'python build_plugin.py --final' + [string] $BuildCommand = '', + + # Glob pattern (relative to GuestWorkDir) of files to include in the artifact zip. + # Used only when BuildCommand is set. Default: collect everything under 'dist'. + # Example: 'dist\**' or 'Contrib\nsInnoUnp\Build\**' + [string] $GuestArtifactSource = 'dist', + + [string] $GuestWorkDir = 'C:\CI\build', + + [string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# ── Session options: skip SSL certificate checks for self-signed (lab use) ── +$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck + +Write-Host "[Invoke-RemoteBuild] Connecting to VM at $IPAddress..." + +$session = New-PSSession ` + -ComputerName $IPAddress ` + -Credential $Credential ` + -SessionOption $sessionOptions ` + -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 + } + New-Item -ItemType Directory -Path $workDir -Force | Out-Null + } -ArgumentList $GuestWorkDir, $GuestArtifactZip + + # ── Copy source tree: compress on host, transfer zip, expand in guest ── + # 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 + $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' + Copy-Item -Path $hostZip -Destination $guestZip -ToSession $session -Force + Write-Host "[Invoke-RemoteBuild] Expanding archive in guest..." + Invoke-Command -Session $session -ScriptBlock { + param($zip, $dest) + Expand-Archive -Path $zip -DestinationPath $dest -Force + Remove-Item $zip -Force + } -ArgumentList $guestZip, $GuestWorkDir + Write-Host "[Invoke-RemoteBuild] Source transfer complete." + } + finally { + Remove-Item $hostZip -Force -ErrorAction SilentlyContinue + } + + # ── Build ────────────────────────────────────────────────────────── + if ($BuildCommand -ne '') { + # ── Custom build command (e.g. python, msbuild, cmake…) ────────── + Write-Host "[Invoke-RemoteBuild] Running build command: $BuildCommand" + $buildResult = Invoke-Command -Session $session -ScriptBlock { + param($workDir, $cmd, $artifactZip, $artifactSrc) + Set-Location $workDir + $output = Invoke-Expression "$cmd 2>&1" | ForEach-Object { "$_" } + $buildExit = $LASTEXITCODE + + if ($buildExit -eq 0) { + $archiveDir = Split-Path $artifactZip -Parent + if (-not (Test-Path $archiveDir)) { + New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null + } + $srcPath = Join-Path $workDir $artifactSrc + Compress-Archive -Path $srcPath -DestinationPath $artifactZip -Force + } + + return @{ ExitCode = $buildExit; Output = ($output -join "`n") } + } -ArgumentList $GuestWorkDir, $BuildCommand, $GuestArtifactZip, $GuestArtifactSource + + Write-Host $buildResult.Output + if ($buildResult.ExitCode -ne 0) { + throw "Build command failed (exit $($buildResult.ExitCode))" + } + } + else { + # ── Default: dotnet restore + dotnet build ──────────────────────── + Write-Host "[Invoke-RemoteBuild] Running dotnet restore..." + $restoreResult = Invoke-Command -Session $session -ScriptBlock { + param($workDir) + Set-Location $workDir + $output = dotnet restore 2>&1 + return @{ ExitCode = $LASTEXITCODE; Output = ($output -join "`n") } + } -ArgumentList $GuestWorkDir + + Write-Host $restoreResult.Output + if ($restoreResult.ExitCode -ne 0) { + throw "dotnet restore failed (exit $($restoreResult.ExitCode))" + } + + Write-Host "[Invoke-RemoteBuild] Running dotnet build (configuration: $Configuration)..." + $buildResult = Invoke-Command -Session $session -ScriptBlock { + param($workDir, $config, $artifactZip) + Set-Location $workDir + $outputDir = Join-Path $workDir 'bin\CIOutput' + $output = dotnet build --configuration $config --output $outputDir 2>&1 + $buildExit = $LASTEXITCODE + + if ($buildExit -eq 0) { + $archiveDir = Split-Path $artifactZip -Parent + if (-not (Test-Path $archiveDir)) { + New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null + } + Compress-Archive -Path "$outputDir\*" -DestinationPath $artifactZip -Force + } + + return @{ ExitCode = $buildExit; Output = ($output -join "`n") } + } -ArgumentList $GuestWorkDir, $Configuration, $GuestArtifactZip + + Write-Host $buildResult.Output + if ($buildResult.ExitCode -ne 0) { + throw "dotnet build failed (exit $($buildResult.ExitCode))" + } + } + + Write-Host "[Invoke-RemoteBuild] Build succeeded. Artifact at $GuestArtifactZip" +} +finally { + Remove-PSSession $session -ErrorAction SilentlyContinue +} diff --git a/scripts/New-BuildVM.ps1 b/scripts/New-BuildVM.ps1 new file mode 100644 index 0000000..699baa7 --- /dev/null +++ b/scripts/New-BuildVM.ps1 @@ -0,0 +1,114 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Creates a linked clone of the template VM for an ephemeral CI build. + +.DESCRIPTION + Uses vmrun.exe to create a linked clone from a frozen template snapshot. + Returns the full path to the new clone's .vmx file on success. + The template VM and its snapshot are never modified. + +.PARAMETER TemplatePath + Full path to the template VM's .vmx file. + Example: F:\CI\Templates\WinBuild\WinBuild.vmx + +.PARAMETER SnapshotName + Name of the snapshot on the template VM to clone from. + Default: BaseClean + +.PARAMETER CloneBaseDir + Directory under which the new clone folder will be created. + The clone folder is named after the job ID and timestamp. + Example: F:\CI\BuildVMs + +.PARAMETER JobId + Unique identifier for the CI job (e.g. run ID from Gitea Actions). + Used to name the clone: Clone_{JobId}_{yyyyMMdd_HHmmss} + +.PARAMETER VmrunPath + Full path to vmrun.exe. + Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe + +.OUTPUTS + [string] Full path to the new clone's .vmx file. + +.EXAMPLE + $vmxPath = .\New-BuildVM.ps1 ` + -TemplatePath "F:\CI\Templates\WinBuild\WinBuild.vmx" ` + -CloneBaseDir "F:\CI\BuildVMs" ` + -JobId "run-42" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path $_ -PathType Leaf })] + [string] $TemplatePath, + + [string] $SnapshotName = 'BaseClean', + + [Parameter(Mandatory)] + [string] $CloneBaseDir, + + [Parameter(Mandatory)] + [string] $JobId, + + [string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' +) + +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." +} + +# --- Build clone path --- +$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' +$cloneName = "Clone_${JobId}_${timestamp}" +$cloneDir = Join-Path $CloneBaseDir $cloneName +$cloneVmx = Join-Path $cloneDir "$cloneName.vmx" + +# Ensure clone base dir exists +if (-not (Test-Path $CloneBaseDir)) { + New-Item -ItemType Directory -Path $CloneBaseDir -Force | Out-Null +} + +Write-Host "[New-BuildVM] Creating linked clone..." +Write-Host " Template : $TemplatePath" +Write-Host " Snapshot : $SnapshotName" +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 + +$sw.Stop() + +if ($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" +} + +if (-not (Test-Path $cloneVmx -PathType Leaf)) { + throw "vmrun reported success but clone VMX not found at: $cloneVmx" +} + +Write-Host "[New-BuildVM] Clone created in $($sw.Elapsed.TotalSeconds.ToString('F1'))s : $cloneVmx" + +# Return the VMX path as output +return $cloneVmx diff --git a/scripts/Remove-BuildVM.ps1 b/scripts/Remove-BuildVM.ps1 new file mode 100644 index 0000000..21a2b3b --- /dev/null +++ b/scripts/Remove-BuildVM.ps1 @@ -0,0 +1,100 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Stops and permanently deletes an ephemeral build VM. + +.DESCRIPTION + Performs a graceful shutdown (soft stop) with a timeout fallback to + a hard stop, then uses vmrun deleteVM to remove all VM files. + Finally, removes the clone directory from disk. + + This script is designed to be called from a try/finally block so + it always runs, even on build failure. + +.PARAMETER VMPath + Full path to the clone VM's .vmx file. + +.PARAMETER VmrunPath + Full path to vmrun.exe. + Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe + +.PARAMETER GracefulTimeoutSeconds + Seconds to wait for graceful shutdown before forcing power-off. + Default: 15 + +.EXAMPLE + .\Remove-BuildVM.ps1 -VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $VMPath, + + [string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe', + + [ValidateRange(5, 60)] + [int] $GracefulTimeoutSeconds = 15 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Continue' # Don't stop on errors — always try next step + +$cloneDir = Split-Path $VMPath -Parent + +Write-Host "[Remove-BuildVM] Destroying VM: $VMPath" + +# ── Step 1: Check if VM exists at all ──────────────────────────────────────── +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) { + Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue + } + return +} + +# ── Step 2: Graceful stop ───────────────────────────────────────────────────── +Write-Host "[Remove-BuildVM] Sending soft stop..." +& $VmrunPath -T ws stop $VMPath soft 2>&1 | Out-Null + +$deadline = (Get-Date).AddSeconds($GracefulTimeoutSeconds) +while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 2 + $stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1 + if ($stateOutput -notmatch 'running') { + Write-Host "[Remove-BuildVM] VM stopped gracefully." + break + } +} + +# ── Step 3: Force stop if still running ────────────────────────────────────── +$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) ─────────────────── +Write-Host "[Remove-BuildVM] Deleting VM..." +$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1 +$deleteExit = $LASTEXITCODE + +if ($deleteExit -ne 0) { + Write-Warning "[Remove-BuildVM] vmrun deleteVM returned exit code $deleteExit : $deleteOutput" + Write-Warning "[Remove-BuildVM] Will attempt manual directory removal." +} + +# ── Step 5: Remove clone directory (catches any leftover files) ─────────────── +if (Test-Path $cloneDir) { + Write-Host "[Remove-BuildVM] Removing clone directory: $cloneDir" + Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $cloneDir) { + Write-Warning "[Remove-BuildVM] Clone directory could not be fully removed: $cloneDir" + Write-Warning " Manual cleanup required." + } else { + Write-Host "[Remove-BuildVM] Clone directory removed." + } +} + +Write-Host "[Remove-BuildVM] VM destruction complete." diff --git a/scripts/Wait-VMReady.ps1 b/scripts/Wait-VMReady.ps1 new file mode 100644 index 0000000..d13d642 --- /dev/null +++ b/scripts/Wait-VMReady.ps1 @@ -0,0 +1,134 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Waits until a build VM is fully ready to accept WinRM connections. + +.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 + + Throws if the VM does not become ready within the timeout period. + +.PARAMETER VMPath + Full path to the clone VM's .vmx file. + +.PARAMETER IPAddress + IP address of the VM on the build network (e.g. 192.168.11.101). + +.PARAMETER TimeoutSeconds + Maximum seconds to wait for the VM to become ready. + Default: 300 (5 minutes) + +.PARAMETER PollIntervalSeconds + Seconds between readiness checks. + Default: 5 + +.PARAMETER VmrunPath + Full path to vmrun.exe. + Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe + +.EXAMPLE + .\Wait-VMReady.ps1 ` + -VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx" ` + -IPAddress "192.168.11.101" +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $VMPath, + + [Parameter(Mandatory)] + [ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')] + [string] $IPAddress, + + [ValidateRange(30, 600)] + [int] $TimeoutSeconds = 300, + + [ValidateRange(3, 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) + # are still checked. + [switch] $SkipPing +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $VmrunPath -PathType Leaf)) { + throw "vmrun.exe not found at: $VmrunPath" +} + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$attempt = 0 +$phase = 'vmrun-state' # tracks current phase for informative output +$lastPhase = '' + +Write-Host "[Wait-VMReady] Waiting for VM ready (timeout: ${TimeoutSeconds}s, IP: $IPAddress)..." + +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 + } + + if (-not $isRunning) { + if ($phase -ne 'vmrun-state') { + $phase = 'vmrun-state' + } + if ($lastPhase -ne $phase) { + Write-Host "[Wait-VMReady] Phase 1/3: waiting for VM to enter running state..." + $lastPhase = $phase + } + Start-Sleep -Seconds $PollIntervalSeconds + continue + } + + # ── Phase 2: Network ping (optional) ─────────────────────────────── + if (-not $SkipPing) { + $pingOk = Test-Connection -ComputerName $IPAddress -Count 1 -Quiet -ErrorAction SilentlyContinue + + if (-not $pingOk) { + $phase = 'ping' + if ($lastPhase -ne $phase) { + Write-Host "[Wait-VMReady] Phase 2/3: VM running, waiting for network (ping $IPAddress)..." + $lastPhase = $phase + } + Start-Sleep -Seconds $PollIntervalSeconds + continue + } + } + + # ── Phase 3: WinRM responsive ──────────────────────────────────────── + 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 + } + catch { + $phase = 'winrm' + if ($lastPhase -ne $phase) { + Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5985..." + $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" diff --git a/template/Prepare-TemplateSetup.ps1 b/template/Prepare-TemplateSetup.ps1 new file mode 100644 index 0000000..0a193d0 --- /dev/null +++ b/template/Prepare-TemplateSetup.ps1 @@ -0,0 +1,242 @@ +#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 +} diff --git a/template/Setup-TemplateVM.ps1 b/template/Setup-TemplateVM.ps1 new file mode 100644 index 0000000..b854b5a --- /dev/null +++ b/template/Setup-TemplateVM.ps1 @@ -0,0 +1,659 @@ +#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: " + $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 ""