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.
This commit is contained in:
+44
@@ -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
|
||||
~$*
|
||||
@@ -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-<jobid>`
|
||||
- **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\<jobid>\`
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# TODO — Local CI/CD System
|
||||
|
||||
<!-- Last updated: 2026-05-08 — e2e-009 SUCCESS, sistema production-ready -->
|
||||
|
||||
## 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 <IP_KMS>` 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 |
|
||||
@@ -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 <template.vmx> <clone.vmx> linked ──►│
|
||||
Duration: ~5–15 seconds │
|
||||
│
|
||||
3. START │
|
||||
vmrun start <clone.vmx> 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
|
||||
@@ -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
|
||||
+254
@@ -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 <template.vmx> <clone.vmx> 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 <clone.vmx> 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 <clone.vmx> -IPAddress <dynamic-NAT-ip> -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 <dynamic-NAT-ip> # 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 <dynamic-NAT-ip>
|
||||
-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 <clone.vmx>
|
||||
```
|
||||
|
||||
Sequence:
|
||||
|
||||
```
|
||||
1. vmrun stop <clone.vmx> soft (graceful shutdown attempt)
|
||||
2. Wait 5 seconds
|
||||
3. vmrun getState → if still "running":
|
||||
vmrun stop <clone.vmx> hard (force off)
|
||||
4. vmrun deleteVM <clone.vmx> (removes VMX + VMDK delta files)
|
||||
5. Remove-Item <clone dir> -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** |
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: <error message>"
|
||||
$vsExit = if ($rawResult -match '^\d+$') { [int]$rawResult } else { 99 }
|
||||
$vsMsg = if ($vsExit -eq 99) { $rawResult } else { '' }
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
Remove-Item $vsWorkerPath, $vsResultPath, $vsInstallerPath -ErrorAction SilentlyContinue
|
||||
|
||||
if ($vsExit -notin @(0, 3010)) {
|
||||
throw "VS Build Tools installation failed (exit $vsExit). $vsMsg"
|
||||
}
|
||||
Write-Host "VS Build Tools 2026 installed (exit code $vsExit)."
|
||||
}
|
||||
|
||||
# Add MSBuild to system PATH
|
||||
$msbuildDir = Split-Path $msbuildPath -Parent
|
||||
$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
|
||||
if ($currentPath -notlike "*$msbuildDir*") {
|
||||
[System.Environment]::SetEnvironmentVariable(
|
||||
'PATH',
|
||||
"$currentPath;$msbuildDir",
|
||||
'Machine'
|
||||
)
|
||||
Write-Host "MSBuild added to system PATH."
|
||||
}
|
||||
|
||||
# ── Step 9: Verify toolchain ───────────────────────────────────────────────
|
||||
Write-Step "Verifying toolchain"
|
||||
|
||||
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
|
||||
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
|
||||
|
||||
$allOk = $true
|
||||
|
||||
# Helper: resolve a binary — tries hardcoded path first, then Get-Command (PATH)
|
||||
function Resolve-Tool {
|
||||
param([string]$HardcodedPath, [string]$CommandName)
|
||||
if ($HardcodedPath -and (Test-Path $HardcodedPath -PathType Leaf)) {
|
||||
return $HardcodedPath
|
||||
}
|
||||
$found = Get-Command $CommandName -ErrorAction SilentlyContinue
|
||||
if ($found) { return $found.Source }
|
||||
return $null
|
||||
}
|
||||
|
||||
# dotnet
|
||||
$dotnetBin = Resolve-Tool '' 'dotnet'
|
||||
if ($dotnetBin) {
|
||||
$v = & $dotnetBin --version 2>&1 | Select-Object -First 1
|
||||
Write-Host " [OK] dotnet: $v ($dotnetBin)"
|
||||
} else {
|
||||
Write-Warning " [FAIL] dotnet not found"
|
||||
$allOk = $false
|
||||
}
|
||||
|
||||
# python
|
||||
$pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python'
|
||||
if ($pythonBin) {
|
||||
$v = & $pythonBin --version 2>&1 | Select-Object -First 1
|
||||
Write-Host " [OK] python: $v ($pythonBin)"
|
||||
} else {
|
||||
Write-Warning " [FAIL] python not found"
|
||||
$allOk = $false
|
||||
}
|
||||
|
||||
# msbuild
|
||||
$msbuildBin = Resolve-Tool $msbuildPath 'msbuild'
|
||||
if ($msbuildBin) {
|
||||
$v = & $msbuildBin -version 2>&1 | Select-Object -First 1
|
||||
Write-Host " [OK] msbuild: $v ($msbuildBin)"
|
||||
} else {
|
||||
Write-Warning " [FAIL] msbuild not found at '$msbuildPath' and not in PATH"
|
||||
$allOk = $false
|
||||
}
|
||||
|
||||
if (-not $allOk) {
|
||||
Write-Warning "Some tools failed verification. Review above and rerun if needed."
|
||||
}
|
||||
|
||||
# ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||
Write-Step "Cleaning up disk"
|
||||
|
||||
# 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags
|
||||
# First register the flags via /sageset:1 in the registry (silent, no UI)
|
||||
$cleanKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches'
|
||||
$sageset = 1
|
||||
$cleanCategories = @(
|
||||
'Active Setup Temp Folders',
|
||||
'BranchCache',
|
||||
'Downloaded Program Files',
|
||||
'Internet Cache Files',
|
||||
'Memory Dump Files',
|
||||
'Old ChkDsk Files',
|
||||
'Previous Installations',
|
||||
'Recycle Bin',
|
||||
'Service Pack Cleanup',
|
||||
'Setup Log Files',
|
||||
'System error memory dump files',
|
||||
'System error minidump files',
|
||||
'Temporary Files',
|
||||
'Temporary Setup Files',
|
||||
'Thumbnail Cache',
|
||||
'Update Cleanup',
|
||||
'Upgrade Discarded Files',
|
||||
'Windows Defender',
|
||||
'Windows Error Reporting Archive Files',
|
||||
'Windows Error Reporting Files',
|
||||
'Windows Error Reporting Queue Files',
|
||||
'Windows Error Reporting Temp Files',
|
||||
'Windows ESD installation files',
|
||||
'Windows Upgrade Log Files'
|
||||
)
|
||||
foreach ($cat in $cleanCategories) {
|
||||
$keyPath = "$cleanKey\$cat"
|
||||
if (Test-Path $keyPath) {
|
||||
Set-ItemProperty -Path $keyPath -Name "StateFlags$('{0:D4}' -f $sageset)" -Value 2 -Type DWord -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Write-Host "Running cleanmgr /sagerun:$sageset (may take a few minutes)..."
|
||||
$proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" -Wait -PassThru
|
||||
Write-Host "cleanmgr exited (code $($proc.ExitCode))."
|
||||
|
||||
# 2. Clear Windows Update cache
|
||||
Write-Host "Stopping Windows Update service..."
|
||||
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item 'C:\Windows\SoftwareDistribution\Download\*' -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
|
||||
Write-Host "Windows Update download cache cleared."
|
||||
|
||||
# 3. Clear CBS / component store logs
|
||||
Remove-Item 'C:\Windows\Logs\CBS\*' -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 4. Clear TEMP folders
|
||||
Remove-Item 'C:\Windows\Temp\*' -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 5. Clear Prefetch
|
||||
Remove-Item 'C:\Windows\Prefetch\*' -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 6. Run DISM component store cleanup (removes superseded update components)
|
||||
Write-Host "Running DISM component store cleanup (StartComponentCleanup)..."
|
||||
$dism = Start-Process -FilePath 'dism.exe' `
|
||||
-ArgumentList '/Online','/Cleanup-Image','/StartComponentCleanup','/ResetBase' `
|
||||
-Wait -PassThru
|
||||
Write-Host "DISM exited (code $($dism.ExitCode))."
|
||||
|
||||
Write-Host "Disk cleanup complete."
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " Template VM setup complete!" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " MANDATORY NEXT STEPS:"
|
||||
Write-Host ""
|
||||
Write-Host " 1. SWITCH NIC TO HOST-ONLY (critical before snapshot!):"
|
||||
Write-Host " VMware Workstation -> VM -> Settings -> Network Adapter"
|
||||
Write-Host " Change from: VMnet8 (NAT)"
|
||||
Write-Host " Change to: Custom: VMnet11"
|
||||
Write-Host ""
|
||||
Write-Host " 2. Shut down this VM: Start -> Shut down"
|
||||
Write-Host ""
|
||||
Write-Host " 3. Take snapshot in VMware Workstation:"
|
||||
Write-Host " VM -> Snapshot -> Take Snapshot"
|
||||
Write-Host " Name it EXACTLY: BaseClean"
|
||||
Write-Host ""
|
||||
Write-Host " 4. Leave VM powered off permanently."
|
||||
Write-Host ""
|
||||
Write-Host " 5. On the HOST - store credentials in Windows Credential Manager:"
|
||||
Write-Host " New-StoredCredential -Target 'BuildVMGuest' -UserName '$BuildUsername' ``"
|
||||
Write-Host " -Password '$BuildPassword' -Persist LocalMachine"
|
||||
Write-Host " (Run in an elevated PowerShell with CredentialManager module)"
|
||||
Write-Host ""
|
||||
Reference in New Issue
Block a user