37 Commits

Author SHA1 Message Date
Simone d4e619ddc2 feat(template): add WinBuild2022 script variants (Deploy/Prepare/Setup) 2026-05-10 18:17:47 +02:00
Simone 8b54ca01b3 feat(setup): Step 3b — Windows KMS activation via slmgr /skms + /ato
Activates Windows against kms8.msguides.com before Windows Update runs
so WU sees correct license state. Uses cscript //NoLogo to route slmgr
output to stdout (dialogs hang in WinRM sessions). Best-effort: activation
failure emits a warning but does not abort Setup — CI builds function
within the grace period.

Placed between Step 3 (WinRM/network confirmed) and Step 4 (user creation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:27:23 +02:00
Simone d63611f78e fix(setup): $sdFiles.Count null-dereference under StrictMode — wrap in @()
$sdFiles is $null when SoftwareDistribution\Download is empty. StrictMode
throws on $null.Count. @($sdFiles).Count safely returns 0 for null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:20:26 +02:00
Simone ee7f406f2a fix(setup): Measure-Object .Sum null-dereference under Set-StrictMode -Version Latest
PS 5.1 treats GenericMeasureInfo.Sum as absent (not null) when the input pipe
is empty. StrictMode throws 'property Sum cannot be found'. Replace Measure-Object
with an explicit foreach loop for SoftwareDistribution size reporting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:17:41 +02:00
Simone edce871644 fix(setup): defeat WaaSMedicSvc wuauserv healing + AutoAdminLogon Final gate
Two persistent Validate-SetupState failures despite §7.4 fixes:

1. wuauserv — Setup's Assert-Steps pass at exit but WaaSMedicSvc (tamper-protected,
   cannot be disabled) polls SCM ~30-60s and resets StartType to Manual.
   Fix: write Start=4 directly to registry key (SCM API alone is overrideable);
   deny WaaSMedicSvc write access to wuauserv registry key via ACL (best-effort,
   non-fatal if WinRM session lacks permission); re-enforce Start=4 after DISM
   in Cleanup; add Pre-Final re-affirmation block before Final gate.

2. AutoAdminLogon — Step 5c re-affirms it mid-script, but Steps 7-10 take hours
   and no Final gate check catches a late reset. Fix: add Pre-Final re-affirmation
   block (same as wuauserv) + add Assert-Step 'Final' 'AutoAdminLogon=1' to Final gate.

Also update WinISO default path to April 2026 refresh ISO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 16:56:28 +02:00
Simone 34b33c5011 fix(winrm-https): PS 5.1 Test-WSMan has no -SessionOption — remove from all call sites
Test-WSMan in Windows PowerShell 5.1 does not support -SessionOption (WSManSessionOption),
causing "Cannot find a parameter matching the name 'SessionOption'" at runtime.

Prepare-WinBuild2025.ps1:
  - Remove Test-WSMan connectivity check step entirely
  - Open New-PSSession directly (handles -SkipCACheck via PSSessionOption)
  - Wrap New-PSSession in try/catch with the same actionable error message
  - Remove $wsmOpt (WSManSessionOption) — no longer needed
  Wait-GuestWinRMReady: replace Test-WSMan probe with New-PSSession probe + Remove-PSSession

Wait-VMReady.ps1:
  - Remove $wsmOpt entirely
  - Replace Test-WSMan -UseSSL -SessionOption with Test-NetConnection -Port 5986
    (TCP open on 5986 = HTTPS listener up = VM ready; credentials not available here)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 15:58:57 +02:00
Simone 68cde01c9d security(sprint1): §1.1/1.3/1.4 — WinRM HTTPS/5986, SHA256 pinning infra, IP regex per-octet
§1.4 — Replace loose IP ValidatePattern with per-octet regex in 4 scripts
  (Invoke-CIJob, Invoke-RemoteBuild, Get-BuildArtifacts, Wait-VMReady)

§1.1 — Migrate all WinRM connections from HTTP/5985 to HTTPS/5986
  - Deploy post-install.ps1: remove AllowUnencrypted=true; self-signed cert + HTTPS listener
    already present; firewall rule restricted to 192.168.79.0/24
  - Setup-WinBuild2025.ps1: assert AllowUnencrypted=false
  - Prepare-WinBuild2025.ps1: preflight uses TCP/5986; Test-WSMan -UseSSL -Port 5986;
    New-PSSession -UseSSL -Port 5986; host AllowUnencrypted save/restore removed (not needed for HTTPS)
  - Invoke-RemoteBuild, Get-BuildArtifacts: New-PSSession -UseSSL -Port 5986 -Authentication Basic
  - Wait-VMReady: New-WSManSessionOption + Test-WSMan -Port 5986 -UseSSL
  - Validate-DeployState, Validate-SetupState: Invoke-Command -UseSSL -Port 5986,
    AllowUnencrypted check → false; AllowUnencrypted host-side removed from both
  - Docs: PLAN, README, ARCHITECTURE, BEST-PRACTICES, HOST-SETUP, WINDOWS-TEMPLATE-SETUP,
    LINUX-TEMPLATE-SETUP, TODO updated

§1.3 — SHA256 hash pinning infrastructure
  - Assert-Hash function + $script:Hashes hashtable added to Setup-WinBuild2025.ps1
  - Assert-Hash called after dotnet-install.ps1, python installer, vs_buildtools.exe downloads
  - Hashes initialized to '' (warn-skip); must be filled before next snapshot refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:46:41 +02:00
Simone 41611d46a7 fix(template): §7.4 Validate-SetupState -Remediate flag + Setup/Deploy tweaks 2026-05-10 14:26:35 +02:00
Simone 7d6ae42fcf fix(template): §7.4 e2e fixes + autologin + validation scripts
Fix emerged during §7.4 e2e test run (2026-05-10):
- Deploy: set UsoSvc to Manual explicitly (Server 2025 default = Automatic)
- Setup cleanup: re-apply Set-Service Disabled on wuauserv/UsoSvc after DISM
  StartComponentCleanup (WaaSMedicSvc resets StartType during component store cleanup)
- Deploy + Setup: add Administrator autologin (AutoAdminLogon, DefaultUserName,
  DefaultPassword, DefaultDomainName) in post-install.ps1; Assert-Step 5c validates it
- Add Validate-DeployState.ps1: standalone host-side check of all Deploy-set state
- Add Validate-SetupState.ps1: standalone host-side check of full post-Setup state
- Mark §7.4, §7.1 and §7.2 e2e validation items as complete in TODO.md
- Update docs: WINDOWS-TEMPLATE-SETUP.md (arch table, validation scripts section,
  autologin in confine Deploy/Setup); TEST-7.4-e2e.md checklist marked done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 11:30:11 +02:00
Simone 57e4a9713e docs: add §7.4 e2e test runbook for Deploy↔Setup refactor validation
Step-by-step guide to validate §7 refactor on a real VM:
Deploy stand-alone, state verification, Prepare with/without -SkipWindowsUpdate,
snapshot promotion. Includes expected values table and checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:19:27 +02:00
Simone ad39aa5cf4 docs(todo): §7 — replace emoji with [x]/[ ] checkboxes, reformat items
Convert §7.1/7.2/7.3 from emoji  + prose into standard [x] checkbox lists.
§7.4 items converted to [ ] open checkboxes. Consistent with rest of TODO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:11:03 +02:00
Simone 260e275b89 refactor(template): §7 — move OS hardening to Deploy, Setup becomes validation-only
§7.1 — Setup Steps 1/3/4b/5b/5c converted to Assert-Step only:
  - Deploy post-install.ps1 now owns: Firewall (all profiles disabled),
    WinRM MaxMemory=2048MB/IdleTimeout=2h/MaxProcesses=25/StartupType=Automatic,
    lock screen (NoLockScreen), SM GPO policy key + scheduled task disable,
    DisableCAD in Winlogon, OOBE non-policy key.
  - Setup no longer re-applies these — validates them, throws if Deploy missed any.

§7.2 — WU lifecycle Strategy B:
  - Deploy: replaces sc.exe disable with GPO locks only (NoAutoUpdate=1,
    DisableWindowsUpdateAccess=1); services remain Manual (Windows default).
  - Setup Step 6 Step A already clears these locks before COM API; Step 6b
    permanently disables services post-update. No Setup changes needed.

§7.3 — Docs updated:
  - Deploy .DESCRIPTION reflects full hardening ownership.
  - Setup .DESCRIPTION marks validation-only steps, updates step ordering rationale.
  - WINDOWS-TEMPLATE-SETUP.md: three-script architecture table, CI-WinBuild.vmx
    path, Fase C step list + rationale + validation table updated.

TODO: §7.1/7.2/7.3 marked done. §7.4 e2e pending (requires new VM snapshot).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 01:27:29 +02:00
Simone ea882c99d7 fix(scripts): update TemplatePath to correct directory for WinBuild2025.vmx 2026-05-10 01:07:31 +02:00
Simone 3c4dd68282 fix(scripts): update default VMX path to WinBuild2025.vmx in Test-NsinnounpBuild.ps1 2026-05-10 01:02:20 +02:00
Simone 96b65d40de feat(template): add Deploy-WinBuild2025.ps1 + autounattend; update TODO
template/Deploy-WinBuild2025.ps1:
  New host-side script that drives the unattended Windows install phase:
  creates the VM, injects autounattend.xml (disabling Defender/firewall/UAC
  before Setup-WinBuild2025.ps1 runs), boots from ISO, waits for first-boot.

template/autounattend.template.xml:
  WiM-based answer file template for Windows Server 2025 unattended install.
  Sets DisableAntiSpyware GPO + RTP off so Defender is fully off at first
  logon (prerequisite for Setup-WinBuild2025.ps1 Step-2 validation-only path).

TODO.md:
  - Date + wording updated (2026-05-10)
  - §1.1: file refs updated to post-refactor line numbers (Step 3 WinRM,
    Deploy-WinBuild2025 Enable-PSRemoting, Invoke-RemoteBuild, Get-BuildArtifacts)
  - §1.2: TrustedHosts audit status corrected (Setup-Host.ps1 never sets '*';
    Prepare appends IP and restores in finally)
  - §1.3: Python/dotnet/VS Build Tools line refs updated
  - §1.5: PAT security constraints expanded with grep-on-log safety net rule
  - §1.6: Defender/Firewall/UAC state updated to reflect Deploy vs Setup split
  - §3.3 deploy reference: VMX path corrected to CI-WinBuild.vmx
  - §3.3 doc ref: WINDOWS-TEMPLATE-SETUP updated to list Deploy step
2026-05-10 01:01:43 +02:00
Simone ab636a0ec1 refactor(template): Defender validation-only + auto WU-reboot loop in Prepare
Setup-WinBuild2025.ps1 (Step 2):
- Defender is now disabled at deploy time by Deploy-WinBuild2025.ps1 via
  DisableAntiSpyware=1 GPO + RTP off during unattended install.
- Step 2 reduced to a single Assert-Step sanity check (GPO key present = 1).
  Exclusion paths removed: scanner is not running, they would be moot.

Prepare-WinBuild2025.ps1 (Setup invocation):
- Replace one-shot Invoke-Command + manual 're-run with -SkipWindowsUpdate'
  message with an automated loop (max 10 iterations):
  * Invoke-GuestSetup helper runs Setup inside the VM and returns exit code.
  * Exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) triggers Restart-Computer on
    the guest, waits for WinRM via Wait-GuestWinRMReady (20 min timeout),
    reopens PSSession and loops.
  * Exit 0 breaks the loop; any other code throws immediately.
  * Hard cap prevents infinite loops if WU never converges.
2026-05-10 01:01:30 +02:00
Simone 889bd9e41b fix(refs): update residual Prepare-TemplateSetup references in host files
Setup-Host.ps1 and docs/HOST-SETUP.md still referenced the old name
Prepare-TemplateSetup.ps1; update to Prepare-WinBuild2025.ps1.
2026-05-10 01:01:19 +02:00
Simone 644258f59c refactor(template): rename Setup-TemplateVM.ps1 -> Setup-WinBuild2025.ps1
- git mv Setup-TemplateVM.ps1 Setup-WinBuild2025.ps1
- git mv Prepare-TemplateSetup.ps1 -> Prepare-WinBuild2025.ps1 (name already in place)
- Updated all references across the workspace:
    template/Prepare-WinBuild2025.ps1 (script copy, assertions, comments)
    template/Setup-WinBuild2025.ps1 (self-references in docblock and final message)
    README.md (directory tree)
    TODO.md (all inline links and task descriptions)
    docs/WINDOWS-TEMPLATE-SETUP.md (table, step descriptions, phase header, troubleshooting)
    docs/LINUX-TEMPLATE-SETUP.md (Windows equivalent reference)
2026-05-09 23:28:36 +02:00
Simone 0f21e61668 docs(todo): merge CONSIGLI backlog, add P0..P3 priorities and rationale
- Unified TODO.md + CONSIGLI.md into single working document
- Added priority levels P0 (security/reliability) through P3 (nice-to-have)
- Added references to new docs/HOST-SETUP.md, WINDOWS-TEMPLATE-SETUP.md,
  LINUX-TEMPLATE-SETUP.md
- Documented Assert-Step validation flow in Fase B
- Updated open items with rationale and priority tagging
2026-05-09 21:15:43 +02:00
Simone d4099b94fd feat(template): add Assert-Step validation to all provisioning steps
Setup-TemplateVM.ps1:
- Added Assert-Step validation after each install step (WinRM, Firewall,
  User, UAC, Defender, .NET, Python, VS Build Tools, Toolchain, Cleanup)
- Final pre-snapshot gate: all checks must pass before snapshot is allowed
- Disabled Windows Defender + Firewall before installs (saves 5-15 min)
- Disabled UAC (EnableLUA=0, LocalAccountTokenFilterPolicy=1)

Prepare-TemplateSetup.ps1:
- Pre-flight checks: IP validation, TCP/5985 reachability, script presence
- Host WinRM: AllowUnencrypted, TrustedHosts config
- Guest prep: C:\CI exists, file copied, size match
- Post-setup remote validation (9 checks): WinRM, user+admin, UAC,
  firewall, .NET, Python, MSBuild, CI dirs
- StoreCredential flow: New-StoredCredential for BuildVMGuest target
2026-05-09 21:15:39 +02:00
Simone 44f4c3ce7e docs: add host setup and template provisioning guides
- HOST-SETUP.md: bootstrap macchina host (Setup-Host.ps1)
- WINDOWS-TEMPLATE-SETUP.md: provisioning template Windows con Prepare + Setup-TemplateVM
- LINUX-TEMPLATE-SETUP.md: bozza TODO template Linux (non implementato)
2026-05-09 21:15:32 +02:00
Simone a0d66f78c4 chore: ignore Claude orchestrator and worktrees temp dirs 2026-05-09 21:15:27 +02:00
Simone 0d2218cd4d docs: restore verbose TODO format, absorb FIX-TODO open items
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 01:02:30 +02:00
Simone 41212bd439 docs: restore verbose TODO format, absorb FIX-TODO open items
Restore full reference-style TODO (setup phases, commands, e2e log).
Add new entries from FIX-TODO that were dropped in the compact merge:
- Scripts Verification: Cleanup-OrphanedBuildVMs + Remove-BuildVM fix
- Performance & Maintenance: scheduled task for Cleanup
- New "Open Bug Fixes" section: IP octet validation, SHA256 installer
- Reference Paths: add Log dir row
- Gitea Workflow: mark lint.yml done

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 01:02:26 +02:00
Simone 2e568da986 docs: merge FIX-TODO into TODO, remove FIX-TODO.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:59:36 +02:00
Simone 726599edfc docs: merge FIX-TODO into TODO, remove FIX-TODO.md
Collapse completed setup history into a single summary block.
Absorb FIX-TODO open items (IP octet validation, SHA256 installer
check, Cleanup scheduled task) into the appropriate TODO sections.
Remove FIX-TODO.md — single source of truth restored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:59:27 +02:00
Simone 53b7ff05fd fix: wait for vmware-vmx.exe release before deleteVM
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:53:35 +02:00
Simone d2b20284d1 fix: wait for vmware-vmx.exe release before deleteVM
After hard stop, vmware-vmx.exe briefly holds file locks causing
deleteVM to fail with "Insufficient permissions". Replace the fixed
3s sleep with a vmrun-list poll loop (up to 20s) that waits until
the VMX is no longer registered as running, then retry deleteVM up
to 3x with 0/3/6s backoff before falling back to directory removal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:53:32 +02:00
Simone e67cfa6789 test: merge VerifyArtifact default change into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:47:30 +02:00
Simone e6d44dad06 test: enable -VerifyArtifact by default in Test-NsinnounpBuild.ps1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:47:30 +02:00
Simone a1b8ad1c11 test: merge Test-NsinnounpBuild.ps1 into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:46:27 +02:00
Simone 2f2aa2bd9c test: add Test-NsinnounpBuild.ps1 e2e smoke test script
Wraps Invoke-CIJob.ps1 with nsinnounp params, verifies artifact exists.
Optional -VerifyArtifact flag expands zip and checks for expected
DLL/EXE names. Supports -Commit for pinned SHA testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:46:27 +02:00
Simone 8a37d6c8bf fix: merge shallow clone regression fix into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:45:31 +02:00
Simone cc9e5f3969 fix: shallow clone + unshallow fallback for specific commit checkout
Previous fix removed --depth 1 entirely when $Commit was set, causing
full clones on every workflow run (github.sha is always non-empty).

Better approach: keep --depth 1 (fast path), detect checkout failure,
then fetch --unshallow and retry. Only pays full-clone cost when the
requested commit is actually older than HEAD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:45:27 +02:00
Simone 7546d96027 fix: merge all FIX-TODO fixes into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:39:56 +02:00
Simone f373c0c24b fix: apply all FIX-TODO items (P0/P1/P2)
P0 bugs:
- Invoke-CIJob.ps1: skip --depth 1 when $Commit specified (shallow clone
  + checkout specific commit was silently failing)
- Get-BuildArtifacts.ps1: check .Length -eq 0 not rounded sizeKB (false
  fail on artifacts smaller than 50 bytes)
- build-nsis.yml: upgrade upload-artifact v3 -> v4 (v3 deprecated)

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:39:52 +02:00
Simone ecd79c2219 docs: add FIX-TODO with full project review findings
Review post e2e-009: P0 bugs (shallow clone/checkout, artifact size
false-fail, upload-artifact@v3 deprecated), P0 security (hardcoded
password, net user argv, no installer SHA256), P1 stale Host-Only docs,
P2 operational gaps (orphan cleanup, PSScriptAnalyzer, log recursion).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:30:19 +02:00
32 changed files with 8663 additions and 1053 deletions
+4
View File
@@ -42,3 +42,7 @@ $RECYCLE.BIN/
# Temp
*.tmp
~$*
# Copilot Orchestrator temporary files
.orchestrator/
.worktrees/
+1 -1
View File
@@ -16,7 +16,7 @@
- Gitea self-hosted — `http://10.10.20.11:3100`
- act_runner v1.0.2 — Windows service su host, label `windows-build`
- Build VMs: subnet VMnet8 NAT — `192.168.79.0/24`
- WinRM HTTP/5985 (Basic auth, lab-only)
- WinRM HTTPS/5986 (Basic auth over HTTPS, self-signed cert, SkipCACheck lab-only)
### Architettura implementata
1. Gitea Actions enqueue job → act_runner lo prende
+9 -8
View File
@@ -54,7 +54,7 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
| .NET SDK | 10.0.203 |
| Python | 3.13.3 |
| Rete | VMnet8 NAT — `192.168.79.0/24` |
| WinRM | HTTP/5985, Basic auth (lab-only) |
| WinRM | HTTPS/5986, Basic auth, self-signed cert (SkipCACheck — lab-only) |
---
@@ -72,8 +72,8 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
│ └── Remove-BuildVM.ps1 # Stop + rimozione VM clone
├── template/
│ ├── Prepare-TemplateSetup.ps1 # Provisioning automatico template VM (HOST-side)
│ └── Setup-TemplateVM.ps1 # Installazione toolchain nella VM (GUEST-side)
│ ├── Prepare-WinBuild2025.ps1 # Provisioning automatico template VM (HOST-side)
│ └── Setup-WinBuild2025.ps1 # Installazione toolchain nella VM (GUEST-side)
├── gitea/
│ ├── workflows/
@@ -103,7 +103,7 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall
```powershell
# Dalla directory template/, dopo aver installato Windows Server 2025 nella VM
# e annotato il suo IP DHCP su VMnet8:
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.x -SkipWindowsUpdate
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.x -SkipWindowsUpdate
```
Poi spegni la VM e prendi lo snapshot con nome esatto **`BaseClean`**.
@@ -114,7 +114,7 @@ Poi spegni la VM e prendi lo snapshot con nome esatto **`BaseClean`**.
# PowerShell elevato — una volta sola sull'host
Import-Module CredentialManager
New-StoredCredential -Target "BuildVMGuest" -UserName "ci_build" `
-Password "CIBuild!ChangeMe2026" -Persist LocalMachine
-Password "<your-build-password>" -Persist LocalMachine
```
### 3. act_runner
@@ -181,6 +181,7 @@ e aggiorna `-RepoUrl`, `-BuildCommand` e `-GuestArtifactSource`.
## Note di sicurezza
WinRM in modalità HTTP/Basic è **accettabile solo in ambiente lab isolato**.
Per ambienti di produzione o condivisi, migrare a HTTPS/5986 con certificato self-signed
e rimuovere `AllowUnencrypted=true`. Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md).
WinRM usa **HTTPS/5986** con certificato self-signed e Basic auth.
`-SkipCACheck`/`-SkipCNCheck` sono accettabili in lab isolato (il cert non è di una CA trusted).
Per ambienti condivisi o di produzione, usare un certificato CA valida e rimuovere `-SkipCACheck`.
Vedi [docs/BEST-PRACTICES.md](docs/BEST-PRACTICES.md).
+9 -6
View File
@@ -82,7 +82,7 @@ param(
[string] $CIRoot = 'F:\CI',
[string] $GuestCredentialTarget = 'BuildVMGuest',
[string] $GuestUsername = 'ci_build',
[string] $GuestPassword = 'CIBuild!ChangeMe2026',
[string] $GuestPassword = '',
[string] $ActRunnerExe = '',
[string] $ActRunnerConfigYaml = '',
[string] $GiteaUrl = 'http://10.10.20.11:3100',
@@ -167,16 +167,19 @@ if ($existingCred) {
Write-OK "Credential '$GuestCredentialTarget' already exists (user: $($existingCred.UserName))"
Write-Warn "To update: Remove-StoredCredential -Target '$GuestCredentialTarget', then re-run."
} else {
if ($GuestPassword -eq '') {
Write-Host " No -GuestPassword supplied. Enter password for '$GuestUsername' (will be stored in Credential Manager):"
$secPwd = Read-Host -Prompt " Password" -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
$GuestPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
New-StoredCredential `
-Target $GuestCredentialTarget `
-UserName $GuestUsername `
-Password $GuestPassword `
-Persist LocalMachine | Out-Null
Write-OK "Credential '$GuestCredentialTarget' stored (user: $GuestUsername)"
if ($GuestPassword -eq 'CIBuild!ChangeMe2026') {
Write-Warn "Using DEFAULT password — change it before production use!"
Write-Warn "Edit -GuestPassword parameter and re-run, or update in Credential Manager manually."
}
}
# ── Step 5: Copy runner\config.yaml ──────────────────────────────────────────
@@ -341,7 +344,7 @@ Write-Host " NIC: VMnet8 (NAT) — internet required during provisioning"
Write-Host " b. Install Windows Server 2025 + enable WinRM inside VM"
Write-Host " c. From this host run:"
Write-Host " cd $scriptDir\template"
Write-Host " .\Prepare-TemplateSetup.ps1 -VMIPAddress <VM_IP> -SkipWindowsUpdate"
Write-Host " .\Prepare-WinBuild2025.ps1 -VMIPAddress <VM_IP> -SkipWindowsUpdate"
Write-Host " d. Shut down VM, take snapshot named exactly: BaseClean"
Write-Host ""
Write-Host " 4. Register SSH key with Gitea:"
+614 -41
View File
@@ -1,7 +1,50 @@
# TODO — Local CI/CD System
<!-- Last updated: 2026-05-08e2e-009 SUCCESS, sistema production-ready -->
<!-- Last updated: 2026-05-10 — line refs e wording allineati al refactor 2026-05-09 -->
> Documento unico di lavoro: roadmap, audit trail dei task completati, e backlog post-v1.0
> con priorità e razionale. Le voci aperte sono raggruppate per area e marcate **P0..P3**:
>
> **Doc di setup operativi**:
> - [docs/HOST-SETUP.md](docs/HOST-SETUP.md) — bootstrap macchina host (Setup-Host.ps1)
> - [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md) — provisioning template Windows (Deploy + Prepare + Setup-WinBuild2025)
> - [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md) — bozza/TODO template Linux (non implementato)
>
> - **P0** — sicurezza o affidabilità: affrontare prima di estendere il sistema
> - **P1** — operatività quotidiana: automatizzare manutenzione e debug
> - **P2** — performance e qualità del codice
> - **P3** — estensioni nice-to-have
---
## Summary
_Last updated: 2026-05-10 (post §7.4 e2e + tag v1.2)_
**Stato generale**: infrastruttura base e refactor Deploy↔Setup (§7) completi. Sicurezza sprint 1 in corso (HTTPS/5986 done, IP regex done, hash pinning struttura done — valori da riempire). Backlog operatività/perf/qualità intatto.
| Area | Done | Open | Note |
| ------------------------------- | ---: | ---: | ----------------------------------------------------------------- |
| Setup & Infrastructure | all | 0 | Gitea + runner + dirs + rete tutti operativi |
| Template VM (Fasi A→D) | all | 0 | Snapshot `BaseClean` preso, credenziali in Credential Manager |
| Scripts Verification | all | 0 | e2e-008/009 SUCCESS, cleanup orfani + retry deleteVM ok |
| Runner Configuration | all | 0 | `config.yaml`, capacity 4, `BuildVMGuest` |
| Gitea Workflow Integration | 4 | 1 | manca solo §P3 generalizzazione workflow |
| §1 Sicurezza & hardening | 2 | 6 | 1.1, 1.4 done; 1.2/1.3 parzialmente; 1.5/1.6/1.7/1.8 da fare |
| §2 Affidabilità & resilienza | 0 | 7 | tutto backlog (race IP, cleanup schedulato, snapshot versionato…) |
| §3 Performance & throughput | 0 | 6 | nessuna ottimizzazione applicata; baseline §3.6 prerequisito |
| §4 Osservabilità & manutenzione | 0 | 5 | log JSONL, metriche, runbook tutti aperti |
| §5 Qualità codice & test | 0 | 5 | Pester, modulo `_Common.psm1`, regole PSScriptAnalyzer custom |
| §6 Scalabilità & estensioni | 0 | 6 | Linux VM, composite action, toolchain Tier-1 |
| §7 Refactor Deploy ↔ Setup | 4 | 1 | 7.1/7.2/7.3/7.4 done; 7.5 (rinomina) opzionale |
| In-VM Git Clone (§3.3) | 0 | 4 | dipende da §6.6 (Git+7-Zip nel template) |
| Linux Build VM (§6.1) | 0 | 4 | bozza in `docs/LINUX-TEMPLATE-SETUP.md` |
**Prossimi passi consigliati** (vedi anche "Suggerimento di sequencing" in fondo):
1. Chiudere §1.3 — riempire hash SHA256 in `Setup-WinBuild2025.ps1` prima del prossimo refresh snapshot.
2. Audit §1.2 (TrustedHosts `*` su host), poi §1.7 (rotazione password guest).
3. Sprint 2 operatività — §2.2 (cleanup schedulato), §2.3 (retention), §2.5 (snapshot versionato).
---
## Setup & Infrastructure
- [x] **Gitea Server** — già in esecuzione su `http://10.10.20.11:3100` (e `https://gitea.emulab.it`)
@@ -25,6 +68,7 @@
- [x] `F:\CI\Cache\NuGet\`
- [x] `F:\CI\RunnerWork\`
- [x] `F:\CI\Templates\WinBuild\`
- [x] `F:\CI\Logs\` — log per job (retention: 30 giorni)
- [x] `F:\CI\ISO\` — contiene `26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` (Windows Server 2025)
- [x] **CredentialManager** PowerShell module v2.0 installato (Scope: CurrentUser)
@@ -38,7 +82,7 @@
### Fase A — Crea e installa la VM (NIC: NAT per internet)
- [x] Crea nuova VM in VMware Workstation con queste impostazioni:
- 4 vCPU, 6144 MB RAM, disco 80 GB thin
- VMX path: `F:\CI\Templates\WinBuild\WinBuild.vmx`
- VMX path: `F:\CI\Templates\WinBuild\CI-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
@@ -49,7 +93,7 @@
```powershell
Enable-PSRemoting -Force -SkipNetworkProfileCheck
Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force
Set-Item WSMan:\localhost\Service\AllowUnencrypted $true -Force
# AllowUnencrypted left false — Deploy post-install.ps1 configures HTTPS/5986 listener
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
```
- [x] **Attiva Windows Server 2025** (prima dello snapshot — i linked clone ereditano l'attivazione):
@@ -67,23 +111,34 @@
- [x] Dall'host, esegui:
```powershell
cd n:\Code\Workspace\Local-CI-CD-System\template
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.128 -SkipWindowsUpdate
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.128 -SkipWindowsUpdate
```
Installa: WinRM config, utente `ci_build`, .NET SDK 10.0, VS Build Tools 2026, Python 3.13.3.
Lo script esegue validazione automatica ad ogni passaggio (`Assert-Step`):
- Pre-flight: IP ottetti 0-255, TCP/5986 raggiungibile, Setup-WinBuild2025.ps1 presente
- Host WinRM: AllowUnencrypted, TrustedHosts
- Guest prep: `C:\CI` esiste, file copiato, size match
- Post-setup remoto (9 check): WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs
Se qualsiasi check fallisce → script termina con errore → **non prendere lo snapshot**.
- [x] Setup completato con successo (VS Build Tools 2026 exit code 0).
- [x] `Setup-WinBuild2025.ps1` ora esegue validazione `Assert-Step` dopo ogni step interno
(WinRM, Firewall, User, UAC, Dirs, Defender, .NET, Python, VS Build Tools, Toolchain,
Cleanup, Final pre-snapshot gate) — throws se qualsiasi check fallisce (2026-05-09)
### Fase C — Spegni e prendi lo snapshot
- [x] VM rimane su **VMnet8 (NAT)** — internet access permanente per build che richiedono pip/nuget/npm
- [x] Spegni la VM: Start → Shut down
- [x] Prendi snapshot: VM → Snapshot → Take Snapshot
**Nome esatto: `BaseClean`**
**Prerequisito: `Prepare-WinBuild2025.ps1` deve essere uscito con exit 0** — tutti
gli `Assert-Step` passati, incluso il Final pre-snapshot gate nel guest.
- [x] Lascia la VM spenta — non modificarla mai più dopo questo snapshot
### Fase D — Credenziali e config
- [x] Archivia credenziali guest sull'host (PowerShell elevato con CredentialManager):
```powershell
New-StoredCredential -Target "BuildVMGuest" -UserName "ci_build" `
-Password "CIBuild!ChangeMe2026" -Persist LocalMachine
-Password "<your-build-password>" -Persist LocalMachine
```
- [x] Verifica `GITEA_CI_TEMPLATE_PATH` in `F:\CI\act_runner\config.yaml` — aggiornato a `CI-WinBuild.vmx` (2026-05-08)
@@ -97,6 +152,8 @@
- [x] Test `Invoke-CIJob.ps1` end-to-end su una soluzione reale (nsis-plugin-nsinnounp)
- [x] e2e-008: SUCCESS — build OK + artifact raccolto (1632 KB)
- [x] e2e-009: SUCCESS — cleanup automatico VM + clone confermato (finally block)
- [x] `scripts/Cleanup-OrphanedBuildVMs.ps1` creato e verificato — `-WhatIf`, soglia `-MaxAgeHours`, hard stop + deleteVM + rimozione dir (2026-05-09)
- [x] `scripts/Remove-BuildVM.ps1` fix `vmrun deleteVM` exit -1 — poll `vmrun list` + retry 3× backoff 0/3/6s (2026-05-09)
## Runner Configuration
@@ -111,18 +168,536 @@
(workflow `build-nsis.yml` già presente e funzionante per `nsis-plugin-nsinnounp`)
- [x] Push commit e verificare job in Gitea Actions UI
- [x] Verificare artifact scaricabile da Gitea dopo build riuscita
- [ ] Adattare workflow per altri repository (generalizzare `BUILD_COMMAND` e `ARTIFACT_SOURCE`)
- [x] `gitea/workflows/lint.yml` creato — PSScriptAnalyzer su push/PR che toccano `.ps1` (2026-05-09)
- [ ] **[P3] Adattare workflow per altri repository** — generalizzare `BUILD_COMMAND` e `ARTIFACT_SOURCE`.
Vedi anche §6.2 (composite action riutilizzabile).
## Performance & Maintenance
---
- [ ] Benchmark: tempo creazione clone + tempo WinRM readiness
- [ ] Configurare NuGet package cache su shared folder host (vedi OPTIMIZATION.md)
- [ ] Pianificare refresh semestrale snapshot template VM (KMS lease = 180 giorni):
Boot template su VMnet8 (NAT) → `slmgr /ato` → nuovo snapshot BaseClean
- [ ] Monitoraggio Windows Event Log per fallimenti servizio act_runner
- [ ] Configurare retention artifact (pulizia `F:\CI\Artifacts` più vecchi di N giorni)
## 1. Sicurezza & hardening
## In-VM Git Clone (Ottimizzazione)
### 1.1 [P0] [x] Migrare WinRM da HTTP/Basic a HTTPS/5986 — COMPLETATO 2026-05-10
**Azioni**:
- [x] Generare cert self-signed nel template *prima* dello snapshot (già in Deploy post-install.ps1).
- [x] Sostituire `New-PSSession -ComputerName $ip` con `-UseSSL -Port 5986 -Authentication Basic` in `Invoke-RemoteBuild.ps1`, `Get-BuildArtifacts.ps1`, `Prepare-WinBuild2025.ps1`, `Validate-*.ps1`.
- [x] Rimuovere `AllowUnencrypted=true` da Deploy post-install.ps1 e Setup-WinBuild2025.ps1 (assertion → false).
- [x] `-SkipCACheck`/`-SkipCNCheck`/`-SkipRevocationCheck` mantenuti nei `New-PSSessionOption` e `New-WSManSessionOption` (cert lab self-signed — documentato inline).
- [x] Regola firewall `WinRM-HTTPS` in Deploy ristretta a `RemoteAddress '192.168.79.0/24'`.
- [x] `Wait-VMReady.ps1` usa `New-WSManSessionOption` + `Test-WSMan -Port 5986 -UseSSL`.
- [x] `Prepare-WinBuild2025.ps1`: preflight TCP/5986, host non imposta più `AllowUnencrypted`; solo `TrustedHosts` viene salvato/ripristinato.
- [x] `Validate-DeployState.ps1`, `Validate-SetupState.ps1`: connessione HTTPS/5986, check `AllowUnencrypted=false`.
### 1.2 [P0] [ ] Restringere `TrustedHosts` lato host (audit-only — residuo: audit host pre-esistenti)
File: nessuno script imposta `*` sull'host. Stato corrente:
- `Setup-Host.ps1` non tocca TrustedHosts (verificato 2026-05-10)
- [template/Prepare-WinBuild2025.ps1](template/Prepare-WinBuild2025.ps1) appende `$VMIPAddress` ai `TrustedHosts` e ripristina nel `finally` (post-§1.1: solo TrustedHosts, no AllowUnencrypted)
- [docs/HOST-SETUP.md:121-126](docs/HOST-SETUP.md:121) raccomanda `'192.168.79.*'` in setup manuale
**Azione residua**: audit eventuali host già configurati con `*` (eredità manuale) e
sostituire con la subnet build:
```powershell
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate
```
### 1.3 [P0] [ ] Pinning hash SHA256 degli installer — STRUTTURA AGGIUNTA 2026-05-10 (valori da riempire)
`$script:Hashes` + `Assert-Hash` aggiunti a [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
`Assert-Hash` viene chiamato dopo ogni download (dotnet-install.ps1, python installer, vs_buildtools.exe).
Se l'hash è `''` viene emesso un warning ma l'esecuzione continua (non-breaking).
**Azione residua — OBBLIGATORIA prima del prossimo refresh snapshot**:
- [ ] Scaricare i tre file, calcolare hash, riempire `$script:Hashes` in Setup-WinBuild2025.ps1:
```powershell
# Inside or outside VM — qualsiasi PowerShell:
(Get-FileHash 'python-3.13.3-amd64.exe' -Algorithm SHA256).Hash # → $script:Hashes['Python']
(Get-FileHash 'dotnet-install.ps1' -Algorithm SHA256).Hash # → $script:Hashes['DotNetInstallScript']
(Get-FileHash 'vs_buildtools.exe' -Algorithm SHA256).Hash # → $script:Hashes['VSBuildToolsBootstrapper']
```
- [ ] Verificare che `Assert-Hash` throwi correttamente su hash errato (test con hash fake).
### 1.4 [P1] [x] Validazione IP per-ottetto — COMPLETATO 2026-05-10
Regex per-ottetto sostituita in tutti e 4 i file:
`'^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$'`
Estrazione in `scripts/_Common.psm1` — vedi §5.2 (P2, backlog).
### 1.5 [P1] [ ] PAT mai persistito quando si abilita `-UseGitClone`
Requisiti di sicurezza per l'implementazione del clone in-VM. La sequenza operativa
(quando, dove, come) è dettagliata nella sezione "In-VM Git Clone (Ottimizzazione) — riferimento §3.3".
Vincoli da rispettare in qualsiasi implementazione di `-UseGitClone`:
- [ ] PAT recuperato da Credential Manager **subito prima** della WinRM session, mai prima.
- [ ] Iniettato via `$using:cred` o env var di sessione, mai in argv né in log/transcript.
- [ ] Cancellato in `finally` interno alla scriptblock guest.
- [ ] **Grep automatico post-job sui log**: se il PAT compare grezzo in `$jobLog` o
`transcript.txt`, marcare la build come fallita e ruotare il PAT (regola di safety net,
non sostituisce le precedenti).
### 1.6 [P2] [ ] Defender + Firewall + UAC tutti disattivati nel template — documentare il modello di minaccia
File: [template/Deploy-WinBuild2025.ps1:485-551](template/Deploy-WinBuild2025.ps1) (UAC, ServerManager, DisableCAD, OOBE — set da Deploy), [template/Setup-WinBuild2025.ps1:176-336](template/Setup-WinBuild2025.ps1) (Firewall Step 1, Defender Step 2 validation-only post-refactor 2026-05-09, WinRM Step 3, User Step 4, UAC Step 4b, Dirs Step 5).
Stato attuale (post-refactor §7):
- **Defender**: disabled da Deploy (`DisableAntiSpyware=1` GPO + RTP off) — Setup Step 2 è validation-only
- **Firewall**: disabled da Setup Step 1 (`Set-NetFirewallProfile ... -Enabled False`); §7.1 prevede spostamento a Deploy
- **UAC**: `EnableLUA=0`, `LocalAccountTokenFilterPolicy=1` — duplicato Deploy + Setup Step 4b (§7.1 lo riduce a Assert-Step in Setup)
Le scelte sono ragionevoli per un template lab efimero, ma vanno raccolte in un'unica sezione
`BEST-PRACTICES.md §X — Threat Model & Hardening Trade-offs` che indichi:
- Cosa è disattivato e perché (costo build vs. superficie d'attacco).
- Quali condizioni rendono il modello inaccettabile (es. condivisione dell'host, esposizione
VMnet8 a LAN aziendale, build di codice di terze parti non fidate).
- Mitigazioni se uno di quei vincoli salta (Firewall On con regola WinRM esplicita,
Defender con esclusione `C:\CI`, UAC On con `LocalAccountTokenFilterPolicy=1`).
Senza questa nota, future modifiche possono accumulare ulteriori riduzioni di sicurezza
senza tracciamento.
### 1.7 [P2] [ ] Rotazione password guest VM
- [ ] Trimestrale. Aggiornare credenziale `BuildVMGuest` in Credential Manager + password
effettiva nel template (richiede refresh snapshot — coordinare con §2.5).
### 1.8 [P2] [ ] Verifica Get-StoredCredential
- [ ] Confermare che `Get-StoredCredential` funzioni correttamente in tutti gli script che
lo usano (post-migrazione HTTPS).
---
## 2. Affidabilità & resilienza
### 2.1 [P0] [ ] Race su IP allocation con `capacity: 4`
File: [runner/config.yaml:17](runner/config.yaml), [scripts/Invoke-CIJob.ps1:244-253](scripts/Invoke-CIJob.ps1).
**Stato osservato**: race non riprodotta in e2e-008/009 (un job alla volta). `capacity: 4`
resta attivo in produzione — bug teorico finché non si esegue stress test parallelo.
**Motivazione**: con 4 job paralleli e DHCP VMnet8, due VM possono ottenere lo stesso IP se
la lease pool è stretta o se due `vmrun start` rispondono troppo vicini.
`getGuestIPAddress` ritorna l'IP visto dalla VM stessa, quindi la collisione non viene
rilevata in fase di Wait-VMReady — apparirà come "WinRM risponde ma è la VM sbagliata".
**Opzioni**:
- **A** — IP allocator file-based con lock: prendere un IP libero da `192.168.79.101..104`,
scriverlo in `F:\CI\State\ip-leases\<jobid>` e iniettarlo come `bootArgs` o via
`vmrun writeVariable` prima di start. Rilascio nel `finally`.
- **B** — DHCP reservation per MAC nel VMware NAT DHCP server (`vmnetdhcp.conf`): assegnare
4 MAC noti, hard-coded nei VMX dei cloni. Più semplice ma richiede gestire il VMX clonato.
- **C** — pool fissato a `capacity: 1` finché non si implementa A o B.
L'opzione A è la più portabile e si integra con il pre-warm pool (§3.4).
### 2.2 [P1] [ ] Cleanup orfani schedulato
File: [scripts/Cleanup-OrphanedBuildVMs.ps1](scripts/Cleanup-OrphanedBuildVMs.ps1) — script pronto, manca lo scheduling.
Estendere `Setup-Host.ps1` per registrare un Task Scheduler:
```powershell
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "N:\Code\Workspace\Local-CI-CD-System\scripts\Cleanup-OrphanedBuildVMs.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 4am -RandomDelay (New-TimeSpan -Minutes 30)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
Register-ScheduledTask -TaskName 'CI-CleanupOrphans' -Action $action -Trigger $trigger -Principal $principal -Force
```
Aggiungere anche un trigger `AtStartup` per gestire crash dell'host.
Suggerito: ogni 6 ore, `-MaxAgeHours 4`.
### 2.3 [P1] [ ] Retention artifact + log automatica
File: [docs/OPTIMIZATION.md:160-181](docs/OPTIMIZATION.md) — la logica c'è solo come snippet, non eseguita.
Stessa pattern di §2.2 ma su `F:\CI\Artifacts` con `-AddDays(-30)` e su `F:\CI\Logs` con la
finestra già configurata in `Invoke-CIJob.ps1`. Aggiungere
`Get-PSDrive F | Where-Object { $_.Free -lt 50GB }` come *guard*: se libero < 50 GB,
retention più aggressiva (7 giorni) e log warning.
### 2.4 [P1] [ ] `Remove-BuildVM.ps1` — supportare `-WhatIf`
File: [scripts/Remove-BuildVM.ps1:28](scripts/Remove-BuildVM.ps1).
Aggiungere `[CmdletBinding(SupportsShouldProcess)]` come fatto in `Cleanup-OrphanedBuildVMs.ps1`.
Permette debug e dry-run senza forkare un secondo script.
### 2.5 [P1] [ ] Snapshot versionato `BaseClean_<yyyyMMdd>`
File: [docs/BEST-PRACTICES.md:142-145](docs/BEST-PRACTICES.md), [scripts/Invoke-CIJob.ps1:100](scripts/Invoke-CIJob.ps1).
**Motivazione**: quando si refresha il template (KMS lease semestrale, update toolchain), il
flusso attuale richiede di sovrascrivere `BaseClean` — i cloni esistenti diventano invalidi
e non c'è rollback.
**Strategia**:
- Naming: `BaseClean_20260509`.
- `runner/config.yaml` aggiunge `GITEA_CI_SNAPSHOT_NAME` come env var.
- `Invoke-CIJob.ps1` legge da env, default `'BaseClean'` per compat.
- Pratica: tieni gli ultimi 2 snapshot, cancella il più vecchio dopo 1 settimana di uso pulito del nuovo.
Si lega al task: refresh semestrale snapshot template VM (KMS lease = 180 giorni).
Boot template su VMnet8 (NAT) → `slmgr /ato` → nuovo snapshot.
### 2.6 [P2] [ ] Backup automatico VMDK template
File: [docs/BEST-PRACTICES.md:130-138](docs/BEST-PRACTICES.md) — solo come snippet manuale.
Pre-snapshot: copia atomica del `parent` VMDK in `F:\CI\Backups\Template_<date>\`. Su errore
di refresh (es. installer rotto), rollback in 1 minuto.
### 2.7 [P2] [ ] Health check del runner + monitoraggio Event Log
File: [docs/BEST-PRACTICES.md:101-115](docs/BEST-PRACTICES.md) — script presente in doc, non operativo.
Schedulare ogni 15 min: query `gitea/api/v1/admin/runners`, se `local-windows-runner` non
`online` → `Restart-Service act_runner` + log evento. Limitare a 3 restart/h con cooldown.
Inoltre: monitoraggio Windows Event Log per fallimenti servizio `act_runner` (alert via
EventLog query schedulata o webhook).
---
## 3. Performance & throughput
### 3.1 [P1] [ ] NuGet/pip cache su shared folder
File: [docs/OPTIMIZATION.md:93-128](docs/OPTIMIZATION.md), [scripts/Invoke-RemoteBuild.ps1:158-194](scripts/Invoke-RemoteBuild.ps1).
**Motivazione**: `F:\CI\Cache\NuGet` esiste come dir, non viene mai usata.
Aggiungere a `CI-WinBuild.vmx`:
```ini
sharedFolder0.present = "TRUE"
sharedFolder0.hostPath = "F:\\CI\\Cache\\NuGet"
sharedFolder0.guestName = "nuget-cache"
```
Poi nello scriptblock di build:
```powershell
$env:NUGET_PACKAGES = '\\vmware-host\Shared Folders\nuget-cache'
```
Equivalente per pip:
```powershell
$env:PIP_CACHE_DIR = '\\vmware-host\Shared Folders\pip-cache'
```
Dopo lo snapshot refresh la cache va riscaldata una volta — accettabile.
### 3.2 [P1] [ ] Sostituire `Compress-Archive` con 7-Zip o robocopy
File: [scripts/Invoke-RemoteBuild.ps1:112,148,185](scripts/Invoke-RemoteBuild.ps1) (3 chiamate `Compress-Archive`).
Prerequisito: 7-Zip nel template — vedi §6.6 e sezione "In-VM Git Clone".
**Motivazione**: `Compress-Archive` è single-threaded e su repo medio-grandi (>100 MB sorgente)
può prendere 20-40 s.
**Alternative**:
- **7-Zip** (richiede installazione nel template — vedi sezione "In-VM Git Clone" §B):
```powershell
& 'C:\Program Files\7-Zip\7z.exe' a -mmt=on -mx1 $hostZip "$HostSourceDir\*"
```
Vantaggio: multi-thread, ratio simile con `-mx1`.
- **robocopy via admin share UNC** (`\\<vmIP>\C$\CI\build`): nessun zip/unzip, supporto delta,
ma richiede SMB aperto sulla VM — confliggente con §1.2.
Misurare con un repo reale: probabile risparmio 10-20 s/build su `nsis-plugin-nsinnounp`.
### 3.3 [P2] [ ] In-VM clone (`-UseGitClone`)
Implementazione disegnata di seguito (sezione "In-VM Git Clone (Ottimizzazione)").
Vincoli sicurezza PAT: vedi §1.5. Tool prerequisiti (Git, 7-Zip): vedi §6.6.
Beneficio reale solo per repo > 200 MB con submoduli grandi; misurare prima.
### 3.4 [P3] [ ] Pre-warm pool di cloni
File: [docs/OPTIMIZATION.md:133-156](docs/OPTIMIZATION.md).
Solo se profilo dimostra che `New-BuildVM` + `Wait-VMReady` (~30-60 s) è il collo di bottiglia.
Con build di 2 minuti, l'overhead è ~40% — vale lo sforzo. Con build di 10+ minuti, no.
Implementazione minima: scheduled task ogni 5 min mantiene 2 cloni avviati in
`F:\CI\WarmPool\`; `Invoke-CIJob` fa `Move-Item` di un clone caldo nel suo job dir e parte
da Phase 4 saltando Phase 2-3.
### 3.5 [P2] [ ] vCPU/RAM tuning per workload
File: [docs/OPTIMIZATION.md:82-89](docs/OPTIMIZATION.md), VMX template.
`numvcpus=4`, `memsize=6144` è ragionevole ma non ottimo per ogni build. Per
`nsis-plugin-nsinnounp` (4 build paralleli interni con thread divisi) 4 vCPU sono già saturati.
Considerare:
- VMX pinning: 6 vCPU per build C++ pesanti, 2 vCPU per build .NET piccoli.
- Esporre `-VMCpu` / `-VMMemory` a `Invoke-CIJob.ps1` con override via workflow.
### 3.6 [P2] [ ] Benchmark baseline
- [ ] Tempo creazione clone + tempo WinRM readiness (per stabilire baseline prima di §3.1/3.2/3.4).
---
## 4. Osservabilità & manutenzione
### 4.1 [P1] [ ] Log strutturati per parsing
File: [scripts/Invoke-CIJob.ps1:178-187](scripts/Invoke-CIJob.ps1).
**Motivazione**: i log attuali sono `Write-Host` testuale. Per dashboard / alerting servono
log machine-readable.
Aggiungere a fianco del transcript un secondo file `invoke-ci.jsonl`:
```powershell
function Write-JobEvent {
param([string]$Phase, [string]$Status, [hashtable]$Data = @{})
$event = @{
ts = (Get-Date).ToString('o')
jobId = $JobId
phase = $Phase
status = $Status
data = $Data
}
$event | ConvertTo-Json -Compress | Add-Content $jsonLog
}
```
Emettere in ogni transizione di fase. Permette grafici (durata media per fase, tasso di
fallimento) con `jq` o Loki/Grafana se in futuro.
### 4.2 [P2] [ ] Metriche su Prometheus textfile
Generare `F:\CI\Metrics\runner.prom` da uno scheduled task ogni 60s:
```
ci_runner_orphan_vms 0
ci_runner_disk_free_gb 423
ci_runner_artifacts_total 247
ci_runner_active_jobs 1
```
Se l'homelab ha già Prometheus + node_exporter, basta
`--collector.textfile.directory=F:\CI\Metrics`.
### 4.3 [P1] [ ] Disk space alert
File: estendere `Setup-Host.ps1` o creare `scripts/Watch-DiskSpace.ps1`.
`F:` riempito = build silenziosamente fallite (linked clone fallirà su `vmrun clone` con
messaggi criptici). Alert via `eventcreate` o webhook Gitea/Discord quando libero < 50 GB.
### 4.4 [P2] [ ] Runbook per incident comuni
File: nuovo `docs/RUNBOOK.md`.
Documentare con copy-pasta:
- "Runner offline in Gitea UI" → check service, log, restart.
- "Tutte le build falliscono in Phase 2" → `vmrun -T ws list`, parent VMDK lock, snapshot mancante.
- "Build lente" → check disk free, vmware-vmx.exe CPU, rete VMnet8.
- "VMX corrotto post-crash host" → restore from `F:\CI\Backups\Template_<latest>`.
Ogni voce: sintomo, comando di triage, fix, escalation.
### 4.5 [P3] [ ] Dashboard read-only
Una pagina HTML statica generata da `Invoke-CIJob.ps1` (append a `F:\CI\dashboard.html`)
con ultime 50 build, durata, esito, link agli artifact. Servita da IIS Express o solo
file:// — utile per debug a colpo d'occhio senza aprire Gitea UI.
---
## 5. Qualità codice & test
### 5.1 [P1] [ ] Pester smoke tests sugli script
File: nuovo `tests/` directory.
**Motivazione**: manca un livello di test sotto l'e2e.
Pester può coprire:
- `New-BuildVM` con vmrun mockato — verifica che il VMX path costruito sia ben formato
per JobId con caratteri speciali.
- `Wait-VMReady` con `Test-WSMan` mockato — verifica timeout, fasi, ritorni.
- `Remove-BuildVM` con `vmrun list` mockato — verifica retry deleteVM.
- Validazione IP regex (§1.4) — table tests.
Eseguire in CI tramite il runner stesso (workflow `lint.yml` esiste già — espandere).
### 5.2 [P2] [ ] Modulo PowerShell condiviso `scripts/_Common.psm1`
**Duplicazioni da estrarre**:
- `New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck` (3 file).
- `ValidatePattern` IP (4 file — vedi §1.4).
- Risoluzione `vmrun.exe` (3 file).
- `Test-Path` + `New-Item` per dir lazy creation.
- Function `Invoke-VmrunCommand` con gestione `$LASTEXITCODE`.
Risultato: meno LoC, fix in un punto solo, più facile da testare.
### 5.3 [P2] [ ] Esecuzione `vmrun` con check exit code uniforme
Pattern attuale ripetuto in tutti gli script che invocano `vmrun`:
```powershell
$out = & $VmrunPath ... 2>&1
if ($LASTEXITCODE -ne 0) { throw "..." }
```
Wrapping in `Invoke-VmrunCommand -Operation 'clone' -Args @(...)` riduce errori di copy-paste
e centralizza retry/log.
### 5.4 [P2] [ ] PSScriptAnalyzer rules custom
File: [gitea/workflows/lint.yml](gitea/workflows/lint.yml) (già esiste).
Aggiungere regole specifiche progetto (`PSScriptAnalyzerSettings.psd1` in root):
- `PSAvoidUsingPlainTextForPassword` — rilevante per [Setup-WinBuild2025.ps1:123](template/Setup-WinBuild2025.ps1:123) (`[string] $BuildPassword``ConvertTo-SecureString -AsPlainText` riga 253).
- `PSAvoidUsingInvokeExpression`.
- `PSUseShouldProcessForStateChangingFunctions`.
- Regole custom: vietare hardcoded `F:\CI\` fuori da config (forzare param/env).
### 5.5 [P3] [ ] Type hints nei param block
Già parzialmente fatto. Estendere a tutti gli script per migliorare IntelliSense e
validazione runtime.
---
## 6. Scalabilità & estensioni
### 6.1 [P2] [ ] Linux Build VM
**Documento di pianificazione dedicato**: [docs/LINUX-TEMPLATE-SETUP.md](docs/LINUX-TEMPLATE-SETUP.md)
— bozza completa con fasi A→H, decisioni di design, differenze rispetto al template Windows.
Vedi anche sezione "Linux Build VM" sotto per il riassunto inline.
**Considerazione architetturale**: SSH su WSL2 non basta — serve VM vera per parità con il
flusso Windows. Ubuntu 24.04 minimal con script bash diretto (parità con flusso Windows).
Sostituire WinRM con SSH key-based; `Invoke-Command` non funziona — usare `ssh.exe` +
`scp.exe` o il modulo `Posh-SSH`.
Aggiungere `scripts/_Transport.psm1` con due implementazioni (`WinRM`, `SSH`) dietro la
stessa interfaccia. `Invoke-CIJob.ps1` sceglie dal parametro `-GuestOS`.
### 6.2 [P3] [ ] Workflow generico riutilizzabile (composite action)
File: [gitea/workflow-example.yml](gitea/workflow-example.yml).
Trasformare in *composite action* Gitea (`.gitea/actions/local-ci-build/action.yml`)
richiamabile da qualsiasi repo:
```yaml
- uses: ./Simone/local-ci-build@v1
with:
build-command: 'python build_plugin.py --final'
artifact-source: 'dist'
submodules: true
```
Riduce la duplicazione su nuovi repo e centralizza l'evoluzione del wrapper.
### 6.3 [P3] [ ] Multi-host runner federation
Quando 4 VM in parallelo non bastano, l'opzione naturale è un secondo host Windows con
stesso `Setup-Host.ps1` registrato come secondo runner Gitea (label `windows-build:host-2`).
Già supportato lato Gitea, basta replicare il setup.
**Premessa**: prima di scalare orizzontale, profilare se il collo di bottiglia è CPU
(i9-10900X 20T è abbondante) o I/O (NVMe). Se è I/O, una seconda NVMe sullo stesso host basta.
### 6.4 [P3] [ ] Build matrix nel workflow
File: [gitea/workflows/build-nsis.yml](gitea/workflows/build-nsis.yml).
Attualmente single job. Quando arriverà la VM Linux:
```yaml
strategy:
matrix:
target: [windows, linux]
runs-on: ${{ matrix.target }}-build
```
### 6.5 [P3] [ ] Secret injection workflow-level
Per chiavi di firma (Authenticode, GPG), usare i Gitea secrets nel workflow e passarli a
`Invoke-CIJob.ps1` come param + env, mai loggati. Stesso modello di §1.5 per il PAT.
### 6.6 [P2] [ ] Toolchain Tier-1 in Setup-WinBuild2025
File: [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
**Motivazione**: toolchain attuale (`.NET SDK`, `Python`, `VS Build Tools`) copre solo C#/Python/MSBuild.
Aggiungere tools generici-CI riduce il bisogno di installazione ad-hoc nei workflow e abilita
build più ampi senza toccare il template.
Tool da aggiungere come step `Setup-WinBuild2025` (ognuno con `Assert-Step`):
| Tool | Versione | Razionale |
| ------------------- | ----------------------- | -------------------------------------------------------------- |
| **Git for Windows** | latest LTS | Self-clone in VM (alt a host-zip-transfer), submodule fetch |
| **7-Zip** | latest | Universale unpack/zip; molti installer e workflow ne dipendono |
| **PowerShell 7.x** | latest LTS | Più veloce di 5.1, parallel pipelines, operatori moderni |
| **NSIS** | latest | Test build installer (es. `nsis-plugin-nsinnounp`) |
| **CMake** | latest | Build system cross-platform per progetti C/C++ nativi |
| **Node.js LTS** | latest LTS | Frontend/Electron pipelines, JS toolchain (npm) |
| **WiX Toolset** | v4 stable | MSI building da .NET projects |
| **gh CLI** | latest | Upload artifact a release Gitea/GitHub, comment PR |
| **Sysinternals** | sysinternals.com `live` | Diagnostica process/handle quando build flaky |
| **vcpkg** | latest | Package manager C++ per dipendenze native |
**Approccio**:
- Pinning hash SHA256 per ogni installer (vedi §1.3)
- Convenzione path guest: tool in `C:\BuildTools\<tool>\` (separato da `C:\CI\` runtime
`C:\CI\` resta per artefatti job e worker temp; `C:\BuildTools\` per software statico).
Documentare in `docs/BEST-PRACTICES.md` prima di implementare.
- Aggiungere a `PATH` machine-wide
- Defender già off → no scan overhead durante install
- Step esegue in seriale dopo `Toolchain cross-check` esistente, prima di `Cleanup`
- `Final pre-snapshot gate`: aggiungere check `where.exe` per ogni tool
- Cross-link: Git for Windows e 7-Zip sono richiesti anche da §3.2 e dalla sezione
"In-VM Git Clone" — implementare qui per evitare doppio lavoro
**Validazione per-tool**:
- Eseguibile risolvibile via `where.exe`
- Versione exact match al param `-<Tool>Version`
- (per gh) `gh --version` returns 0
- (per vcpkg) `vcpkg.exe version`
**Param da aggiungere** a Setup + Prepare:
- `-GitVersion`, `-7ZipVersion`, `-Pwsh7Version`, `-NSISVersion`, `-CMakeVersion`,
`-NodeJSVersion`, `-WiXVersion`, `-GhCliVersion`, `-VcpkgRev` (rev SHA del repo)
**Tier-2 (opzionali, separati)**: Java JDK, Maven/Gradle, Rust, Go, LLVM, Docker, Azure/AWS CLI, WinDbg.
Non includere in Setup base — workflow-level via `choco install` o download diretto.
---
## 7. Refactor confine Deploy ↔ Setup
**Obiettivo**: separazione netta delle responsabilità — Deploy produce **template OS**
stand-alone, Setup fa **build customization** (user, dirs, toolchain). Eliminare drift e
duplicazioni; ogni concern ha **una sola** sorgente di verità.
**Stato attuale (duplicati)**: UAC, Explorer LaunchTo, Server Manager off, DisableCAD,
OOBE telemetry, WU services disable — entrambi gli script li toccano.
### 7.1 [P1] [x] Spostare base OS hardening da Setup a Deploy
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
- [x] Aggiungere `Set-NetFirewallProfile -Enabled False` (tutti i profili) in Deploy post-install.ps1
- [x] Aggiungere `MaxMemoryPerShellMB=2048`, `IdleTimeOut=7200000`, `MaxProcessesPerShell=25`, `StartupType=Automatic` in Deploy dopo Enable-PSRemoting
- [x] Aggiungere in Deploy items mancanti rispetto a Setup 5c: lock screen (`NoLockScreen`), SM GPO policy key (`DoNotOpenAtLogon`), SM scheduled task disable, `DisableCAD` in Winlogon, OOBE non-policy key
- [x] Setup Step 1 (Firewall) → Assert-Step only
- [x] Setup Step 2 (Defender) → già validation-only (refactor 2026-05-09)
- [x] Setup Step 3 (WinRM tuning) → Assert-Step only (rimuovi Enable-PSRemoting, winrm set, Set-Item, Set-Service)
- [x] Setup Step 4b (UAC) → Assert-Step only
- [x] Setup Step 5b (Explorer LaunchTo) → Assert-Step only
- [x] Setup Step 5c (Server Manager / DisableCAD / OOBE) → Assert-Step only
- [x] E2e validation — completata §7.4 (2026-05-10)
### 7.2 [P1] [x] WU lifecycle — strategia B (Deploy: GPO + Manual; Setup: lifecycle)
File: [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1),
[template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1).
Strategia B: Deploy lascia servizi WU `start=demand` (Manual, default Windows) e scrive solo
GPO `NoAutoUpdate=1` + `DisableWindowsUpdateAccess=1`. Setup Step 6 clears i GPO locks (Step A),
avvia WU via SchTask SYSTEM, poi Step 6b disabilita servizi permanentemente post-update.
- [x] Deploy: rimuovere `sc.exe config wuauserv start= disabled` (e UsoSvc, WaaSMedicSvc)
- [x] Deploy: aggiungere GPO `DisableWindowsUpdateAccess=1` (era mancante; `NoAutoUpdate=1` già presente)
- [x] Setup Step 6 Step A: già rimuove `DisableWindowsUpdateAccess` + `NoAutoUpdate` prima del COM API — nessuna modifica necessaria
- [x] Setup Step 6 Step D: già fa `Set-Service start=Manual` + `Start-Service` — idempotente con servizi già Manual
- [x] Setup Step 6b: già disabilita wuauserv/UsoSvc + GPO keys — nessuna modifica necessaria
- [x] E2e validation — completata §7.4 (2026-05-10)
### 7.3 [P2] [x] Update header docs di entrambi gli script
- [x] [template/Deploy-WinBuild2025.ps1](template/Deploy-WinBuild2025.ps1) `.DESCRIPTION`: aggiornato con tutte le hardening voci (firewall, WinRM tuning, WU GPO strategy B, lock screen, SM, OOBE)
- [x] [template/Setup-WinBuild2025.ps1](template/Setup-WinBuild2025.ps1) `.DESCRIPTION`: Steps 1/3/4b/5b/5c marcati "validation-only — set by Deploy"; step ordering rationale aggiornato
- [x] [docs/WINDOWS-TEMPLATE-SETUP.md](docs/WINDOWS-TEMPLATE-SETUP.md): architettura "tre script", VMX path `CI-WinBuild.vmx`, Fase C step list + razionale + tabella validazioni aggiornati
### 7.4 [P2] [x] Test e2e refactor — COMPLETATO 2026-05-10
- [x] Deploy stand-alone su VM test → `Get-NetFirewallProfile` tutti `Enabled=False`, `Get-Service wuauserv | Select StartType``Manual`, `NoAutoUpdate=1` GPO presente
- [x] Prepare dopo Deploy con `-SkipWindowsUpdate` → tutti `Assert-Step` passano `[OK]` senza eseguire Set (log mostra solo validation output)
- [x] Prepare senza `-SkipWindowsUpdate` → WU (0 update trovati, ResultCode=0), Step 6b disabilita wuauserv/UsoSvc (`StartType=Disabled`)
- [x] Final gate del guest passa su tutti e 7 i check; snapshot `BaseClean` preso
**Fix emersi durante §7.4** (applicati):
- Deploy: `UsoSvc` impostato a `Manual` esplicitamente (default Server 2025 = Automatic)
- Setup cleanup: `Set-Service -StartupType Disabled` ri-applicato su wuauserv/UsoSvc dopo DISM `StartComponentCleanup` (DISM/WaaSMedicSvc può resettare StartType)
- Deploy + Setup: autologin Administrator (`AutoAdminLogon=1`) aggiunto in post-install.ps1 e validato in Assert-Step 5c
- Nuovi script: `template/Validate-DeployState.ps1`, `template/Validate-SetupState.ps1`
### 7.5 [P3] [ ] Rinomina Setup → Setup-CITools (opzionale, futuro)
Una volta che Setup fa solo build customization, nome `Setup-WinBuild2025` è impreciso —
non setup-a la build VM, **estende** un template OS già pronto con toolchain CI.
Rinominare in `Setup-CITools.ps1` o `Install-CIToolchain.ps1` rifletterebbe meglio la
responsabilità ridotta. Tracciare in TODO post-7.1/7.2.
---
## In-VM Git Clone (Ottimizzazione) — riferimento §3.3
Sfruttare l'accesso internet della VM (VMnet8 NAT) per fare il `git clone` direttamente
nella VM, eliminando il ciclo host-clone → zip → WinRM-transfer → unzip.
@@ -141,7 +716,7 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
**Punti chiave:**
- Il PAT **non deve essere nello snapshot** — viene iniettato a runtime come env var via WinRM
(`git clone https://Simone:<pat>@gitea.emulab.it/<repo>.git`) e non viene mai persistito
- Git deve essere installato nel template (`Setup-TemplateVM.ps1`) — ora è assente per design
- Git deve essere installato nel template (`Setup-WinBuild2025.ps1`) — ora è assente per design
- L'implementazione deve essere **non-breaking**: nuovo switch opt-in `-UseGitClone` in `Invoke-CIJob.ps1`
- Si perde il parallelismo parziale (clone host avviene mentre VM boota), ma si elimina
tutto l'overhead di zip/transfer (rilevante su repo grandi o con molti submoduli)
@@ -149,28 +724,18 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
### Task
- [ ] **Setup-TemplateVM.ps1 — installare Git for Windows**
- [ ] Scaricare e installare Git for Windows (installer silenzioso da git-scm.com)
```powershell
$gitUrl = 'https://github.com/git-for-windows/git/releases/download/v2.47.1.windows.1/Git-2.47.1-64-bit.exe'
Start-Process $gitUrl '/VERYSILENT /NORESTART /COMPONENTS=gitlfs' -Wait
```
- [ ] Aggiungere `C:\Program Files\Git\cmd` al PATH di sistema nella VM
- [ ] Verificare: `git --version` ritorna exit 0 nella VM
- [ ] Rimuovere il commento "Git is NOT installed" dal docblock di Setup-TemplateVM.ps1
- [ ] **Setup-TemplateVM.ps1 — altri tool utili**
- [ ] **7-Zip**: installer silenzioso — molto più veloce di `Compress-Archive`/`Expand-Archive`
per archivi grandi (es. npm/NuGet cache warm)
- [ ] **curl.exe**: già presente in Windows 11/Server 2025 (v7.x) — verificare disponibilità
- [ ] Valutare: `jq` (parsing JSON in script PowerShell o bash) — opzionale
- [ ] **Tool prerequisiti nel template** — implementati in §6.6 (Tier-1 Toolchain):
- Git for Windows + 7-Zip + gh CLI installati con hash pinning e `Assert-Step`
- Rimuovere il commento "Git is NOT installed" dal docblock di Setup-WinBuild2025.ps1
una volta applicato §6.6
- curl.exe: già presente in Windows Server 2025 — verificare con `where.exe curl`
- [ ] **Invoke-CIJob.ps1 — aggiungere switch `-UseGitClone`**
- [ ] Quando `-UseGitClone` è attivo:
- Saltare Phase 1 (host git clone) e la creazione del `HostSourceDir` temporaneo
- Dopo Wait-VMReady, leggere PAT da Windows Credential Manager sull'host
(es. target `git:https://gitea.emulab.it/...` o un target dedicato CI)
- Passare PAT come parametro a `Invoke-RemoteBuild.ps1` (mai loggato/stampato)
- Passare PAT a `Invoke-RemoteBuild.ps1` rispettando i vincoli sicurezza di §1.5
- [ ] Comportamento default invariato (`-UseGitClone` = `$false`) — nessuna regressione
- [ ] **Invoke-RemoteBuild.ps1 — modalità in-VM clone**
@@ -183,8 +748,8 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
C:\CI\build
git -C C:\CI\build checkout $CloneCommit # pin al commit esatto
```
- [ ] Iniettare PAT come variabile d'ambiente della sessione WinRM (non argv, non log)
- [ ] Pulire la variabile d'ambiente dopo il clone: `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`
- [ ] Implementare iniezione/pulizia PAT secondo §1.5 (env var sessione, no argv, no log,
cleanup `[System.Environment]::SetEnvironmentVariable('CI_GIT_PAT', $null, 'Process')`)
- [ ] **Test e2e con `-UseGitClone`**
- [ ] Aggiornare snapshot `BaseClean` con Git installato
@@ -194,7 +759,7 @@ VM avviata → WinRM: git clone --recurse-submodules https://<token>@gitea/...
---
## Linux Build VM (Future)
## Linux Build VM (Future) — riferimento §6.1
Aggiungere supporto per build su Linux per testare la compilazione cross-platform
(es. versione Linux del plugin o build check con GCC/Clang).
@@ -210,9 +775,10 @@ Aggiungere supporto per build su Linux per testare la compilazione cross-platfor
- [ ] Aggiungere parametro `-GuestOS` (`Windows` / `Linux`) o rilevamento automatico da VMX
- [ ] Sostituire WinRM con SSH + SCP per trasferimento zip e raccolta artifacts
- [ ] Aggiungere supporto a comandi Linux (`bash`, `make`, `cmake --build`)
- [ ] Astrarre transport in `scripts/_Transport.psm1` (vedi §6.1)
- [ ] **Workflow Gitea — build matrix**
- [ ] Definire matrix strategy `[windows-build, linux-build]` nel workflow
- [ ] Definire matrix strategy `[windows-build, linux-build]` nel workflow (vedi §6.4)
- [ ] Aggiungere label `linux-build` al runner (o registrare runner separato se necessario)
- [ ] Verificare che artifacts da entrambe le piattaforme vengano raccolti correttamente
@@ -222,13 +788,19 @@ Aggiungere supporto per build su Linux per testare la compilazione cross-platfor
---
## Security Hardening (Post-MVP)
## Suggerimento di sequencing
- [ ] 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
Sprint progressivi per ridurre rischio / ottenere valore prima:
1. **Sprint 1 (sicurezza)** — §1.1, §1.2, §1.3, §1.4 — superficie di attacco e bug noti.
2. **Sprint 2 (operatività)** — §2.1, §2.2, §2.3, §2.5 — rendere il sistema "fire and forget".
3. **Sprint 3 (osservabilità)** — §4.1, §4.3, §4.4 — visibilità prima di estendere.
4. **Sprint 4 (perf)** — §3.1, §3.2 misurati su un repo reale (baseline §3.6).
5. **Sprint 5 (qualità)** — §5.1, §5.2 — rifattorizzazione con safety net di test.
6. **Sprint 6+ (estensioni)** — Linux VM, workflow generico/composite action, federation.
Tenere ogni sprint piccolo: 1-2 voci alla volta, validare e2e dopo ognuna prima di passare
alla successiva.
---
@@ -244,6 +816,7 @@ Aggiungere supporto per build su Linux per testare la compilazione cross-platfor
| Snapshot name | `BaseClean` |
| Clone base dir | `F:\CI\BuildVMs\` |
| Artifact dir | `F:\CI\Artifacts\` |
| Log dir | `F:\CI\Logs\` (retention: 30 giorni) |
| Gitea URL (LAN) | `http://10.10.20.11:3100` |
| Gitea URL (ext) | `https://gitea.emulab.it` |
| Runner name | `local-windows-runner` (ID: 1) |
+6 -6
View File
@@ -53,7 +53,7 @@ The runner host **never executes build tools directly**. Its only role is orches
│ │ ├── Clone_Job_002.vmx │ │
│ │ └── Clone_Job_003.vmx │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ WinRM :5985 (192.168.79.0/24 NAT)
│ │ WinRM :5986 HTTPS (192.168.79.0/24 NAT) │
└──────────────────────────────────┼──────────────────────────────────────┘
┌────────────────────────┼──────────────────────────┐
@@ -105,8 +105,8 @@ The runner host **never executes build tools directly**. Its only role is orches
### 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)
- Port: **5986** (HTTPS, self-signed cert, `AllowUnencrypted=false`)
- Authentication: **Basic over HTTPS** (`-SkipCACheck` for lab self-signed cert)
- Used for:
- Transferring source: `Compress-Archive` on host → WinRM copy → `Expand-Archive` in guest
- Running build commands (`Invoke-Command`)
@@ -116,8 +116,8 @@ The runner host **never executes build tools directly**. Its only role is orches
- Visual Studio Build Tools **2026** (MSBuild 18.5.4 / toolset v145)
- .NET SDK **10.0.203**
- Python **3.13.3** (per build script personalizzati come `build_plugin.py`)
- Git (per repo clone inside VM)
- NuGet CLI (optional, dotnet restore covers most cases)
- Git: NOT installed by default — source arrives via WinRM zip transfer. See TODO.md `-UseGitClone` for the in-VM git clone opt-in path.
---
@@ -133,7 +133,7 @@ Build VMs:
...
Clone_Job_N → 192.168.79.1xx
WinRM port 5985 (HTTP) on each VM IP.
WinRM port 5986 (HTTPS, self-signed) on each VM IP.
VMs have internet access via NAT — required for pip/nuget during build.
```
@@ -191,4 +191,4 @@ VMs have internet access via NAT — required for pip/nuget during build.
- WinRM credentials are stored in Windows Credential Manager on host (not hardcoded)
- Each VM is fully destroyed after the job — no state carries between builds
- Runner host does not expose any ports to the build VM network
- Source code is transferred via zip (Compress-Archive → WinRM copy → Expand-Archive in guest) — no git clone inside VM
- Source code is transferred via zip (Compress-Archive → WinRM copy → Expand-Archive in guest) — no PAT is ever present inside the VM; VMs have NAT internet for pip/nuget at build time
+25 -42
View File
@@ -30,47 +30,26 @@ $cred = Get-StoredCredential -Target 'BuildVMGuest'
---
## 2. WinRM Security
## 2. WinRM Security — HTTPS/5986 (implementato 2026-05-10)
### Current setup (HTTP / port 5985)
### Setup attuale (HTTPS / port 5986)
Acceptable for an **isolated lab network** where:
- Build VMs sono su VMnet8 NAT (192.168.79.0/24) — raggiungibili dall'host, internet via NAT
- No external traffic reaches port 5985
- Credentials are managed via Windows Credential Manager
`Deploy-WinBuild2025.ps1` post-install.ps1 crea un certificato self-signed e configura
il listener HTTPS/5986 **prima** dello snapshot `BaseClean`. `AllowUnencrypted=false`.
### Recommended upgrade: WinRM over HTTPS (port 5986)
- Build VMs su VMnet8 NAT (192.168.79.0/24) — accesso solo dall'host
- Port 5986 firewall rule ristretta a `RemoteAddress '192.168.79.0/24'`
- Credentials via Windows Credential Manager (target `BuildVMGuest`)
Tutti gli script host usano:
```powershell
# Inside the template VM (before taking BaseClean snapshot):
# Create self-signed certificate
$cert = New-SelfSignedCertificate `
-Subject "CN=$(hostname)" `
-CertStoreLocation Cert:\LocalMachine\My `
-KeyUsage DigitalSignature, KeyEncipherment `
-KeyAlgorithm RSA `
-KeyLength 2048
# Create HTTPS WinRM listener
New-WSManInstance WinRM/Config/Listener `
-SelectorSet @{ Transport = 'HTTPS'; Address = '*' } `
-ValueSet @{ Hostname = $(hostname); CertificateThumbprint = $cert.Thumbprint }
# Allow port 5986
New-NetFirewallRule -Name 'WinRM-HTTPS-CI' -DisplayName 'WinRM HTTPS CI' `
-Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow
```
```powershell
# On the HOST (in scripts), use HTTPS session options:
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL `
$session = New-PSSession -ComputerName $ip -Port 5986 -UseSSL -Authentication Basic `
-Credential $cred -SessionOption $sessionOptions
```
> `-SkipCACheck` is acceptable for a self-signed cert in an isolated lab. Do NOT
> use this against externally accessible machines.
> `-SkipCACheck`/`-SkipCNCheck` sono accettabili per un cert self-signed in lab isolato.
> Non usare contro macchine accessibili dall'esterno — usare una CA trusted in quel caso.
---
@@ -233,27 +212,31 @@ Get-EventLog -LogName Application -Source '*runner*' -Newest 20
---
## 8. Network Isolation Verification
## 8. Network Topology Verification
After setting up the host-only VMware network, verify that build VMs cannot
reach the internet (important for supply-chain security):
Build VMs run on **VMnet8 (NAT)** — they have internet access, which is required
for pip/nuget package downloads at build time. Verify the expected topology:
```powershell
# From inside a build VM via WinRM:
# From inside a build VM via WinRM — confirm NAT internet is reachable:
Invoke-Command -Session $session -ScriptBlock {
$result = Test-Connection 8.8.8.8 -Count 1 -Quiet
if ($result) {
Write-Warning "VM has internet access — check VMware network adapter type"
Write-Host "VM has NAT internet access — expected for pip/nuget builds."
} else {
Write-Host "VM correctly isolated (no internet)"
Write-Warning "VM cannot reach internet — pip/nuget installs will fail. Check VMware NAT service."
}
}
```
Build VMs should only reach:
- The host (for WinRM connection)
- Gitea server (for git clone, if reachable via host-only network)
- NuGet cache share (host-side shared folder)
Build VMs can reach:
- The host via VMnet8 gateway (WinRM HTTPS on port 5986)
- Internet via VMware NAT (for pip, nuget, npm at build time)
- Gitea server if on LAN reachable via NAT gateway
**Supply-chain note:** Source code is always injected by the host via WinRM zip
transfer — never cloned inside the VM using a PAT. This keeps credentials off
the VM even though the VM has outbound internet access.
---
+183
View File
@@ -0,0 +1,183 @@
# Host Setup — Local CI/CD
Procedura di bootstrap della macchina host Windows (i9-10900X, 64 GB RAM, NVMe).
Eseguire **una volta** prima di qualsiasi provisioning del template VM o esecuzione di job CI.
---
## Prerequisiti host
| Componente | Versione/Note |
| ----------------------- | ----------------------------------------------------------------- |
| OS | Windows 11 (host fisico) o Windows Server 2022+ |
| VMware Workstation | Pro 17.x — necessario per `vmrun.exe` e supporto linked clone |
| Storage | NVMe dedicato (consigliato `F:\` riservato a CI, ≥ 500 GB libero) |
| RAM | ≥ 32 GB (4 VM parallele × 6-8 GB) |
| Rete | VMnet8 (NAT) configurato in VMware Workstation |
| PowerShell | 5.1+ (built-in) o 7.x |
| Privilegi | Account amministrativo per Setup-Host.ps1 |
Software opzionali installati on-demand dallo script:
- **NSSM** — installazione automatica via Chocolatey se presente; altrimenti install manuale da https://nssm.cc
- **CredentialManager** module — installato automaticamente (scope CurrentUser)
---
## Directory layout
`Setup-Host.ps1` crea l'intera struttura sotto `$CIRoot` (default `F:\CI\`):
```
F:\CI\
├── BuildVMs\ # cloni temporanei delle VM build (auto-cleanup post-job)
├── Artifacts\ # output build raccolti dall'host
├── Logs\ # log per-job (retention: 30 giorni)
├── Templates\
│ └── WinBuild\ # VMX template Windows (immutabile dopo snapshot)
├── act_runner\
│ ├── act_runner.exe # binario runner Gitea
│ ├── config.yaml # config runner (path, label, capacity)
│ └── logs\ # stdout/stderr del servizio
├── Cache\
│ └── NuGet\ # cache NuGet condivisa via shared folder (TODO §3.1)
├── RunnerWork\ # working dir di act_runner (job staging)
└── ISO\ # ISO di installazione (Windows Server 2025, ecc.)
```
---
## Procedura — esecuzione
### Step 1 — Clonare il repository
```powershell
git clone https://gitea.emulab.it/Simone/Local-CI-CD-System.git N:\Code\Workspace\Local-CI-CD-System
cd N:\Code\Workspace\Local-CI-CD-System
```
### Step 2 — Eseguire Setup-Host.ps1 (elevato)
Apri PowerShell **come Administrator**, poi:
```powershell
# Esecuzione minimale (crea dirs, archivia credenziali, salta install runner)
.\Setup-Host.ps1 -SkipRunnerInstall
# Setup completo con registrazione runner
.\Setup-Host.ps1 -GiteaRunnerToken 'abc123token'
# CI root su drive diverso
.\Setup-Host.ps1 -CIRoot 'D:\CI' -GiteaRunnerToken 'abc123token'
```
### Cosa fa Setup-Host.ps1 — passo per passo
1. **Crea albero directory** sotto `$CIRoot` (vedi layout sopra)
2. **Verifica VMware Workstation** — controlla presenza di `C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe`
3. **Installa modulo CredentialManager** (scope CurrentUser, se non presente)
4. **Archivia credenziali guest VM** in Windows Credential Manager (target `BuildVMGuest`)
- Se non passa `-GuestPassword`, prompt interattivo (SecureString)
- Se credenziale già esistente, non sovrascrive — istruzioni per update
5. **Copia `runner/config.yaml`** in `$CIRoot\act_runner\config.yaml` (se non esistente)
6. **Installa act_runner come servizio Windows** via NSSM:
- Verifica presenza NSSM in PATH; tenta install via `choco install nssm -y`
- Registra runner con Gitea (`act_runner register --no-interactive --instance ... --token ... --name local-windows-runner --labels windows-build:host,dotnet:host,msbuild:host`) se token fornito
- Crea servizio: `AppDirectory`, `AppParameters daemon --config <path>`, log rotation 10 MB
- Avvia servizio (`SERVICE_AUTO_START`)
7. **Configura SSH alias `gitea-ci`** in `~/.ssh/config` (HostName, Port, User git)
---
## Parametri principali
| Parametro | Default | Note |
| ---------------------------- | -------------------------------- | ------------------------------------- |
| `-CIRoot` | `F:\CI` | Base directory per tutti i dati CI |
| `-GuestCredentialTarget` | `BuildVMGuest` | Target Credential Manager |
| `-GuestUsername` | `ci_build` | Account build dentro la VM |
| `-GuestPassword` | (prompt) | Mai hard-codare |
| `-ActRunnerExe` | `<CIRoot>\act_runner\act_runner.exe` | Path binario runner |
| `-ActRunnerConfigYaml` | `<repo>\runner\config.yaml` | Source del config da copiare |
| `-GiteaUrl` | `http://10.10.20.11:3100` | URL Gitea per registrazione |
| `-GiteaRunnerToken` | (vuoto) | Token registrazione (Admin → Runners) |
| `-SkipRunnerInstall` | `$false` | Salta installazione servizio |
| `-SkipSSHConfig` | `$false` | Salta scrittura SSH alias |
| `-GiteaSSHHost` | `10.10.20.11` | Hostname SSH Gitea |
| `-GiteaSSHPort` | `2222` | Porta SSH Gitea |
---
## Post-setup checklist
Allo `Setup-Host.ps1` exit 0:
1. **Posiziona binario act_runner** se non già fatto:
- Path: `F:\CI\act_runner\act_runner.exe`
- Download: https://gitea.com/gitea/act_runner/releases/tag/v1.0.2
2. **Posiziona ISO Windows Server 2025** in `F:\CI\ISO\`
(es. `26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso`)
3. **Configura host WinRM client** (necessario per Prepare-WinBuild2025.ps1):
```powershell
# AllowUnencrypted non serve — Prepare usa HTTPS/5986 (nessun testo in chiaro)
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '192.168.79.*' -Force -Concatenate
```
*Note*: Prepare-WinBuild2025.ps1 aggiunge l'IP specifico della VM in modo transitorio e lo ripristina nel `finally`.
4. **Provisiona il template VM** — vedi [WINDOWS-TEMPLATE-SETUP.md](WINDOWS-TEMPLATE-SETUP.md)
5. **Registra chiave SSH con Gitea**:
- Gitea UI → User Settings → SSH Keys → Add Key
- Chiave pubblica: `%USERPROFILE%\.ssh\id_rsa.pub`
6. **Verifica runner online** in Gitea:
- `<GiteaUrl>/-/admin/runners`
- Status atteso: **Online**, label `windows-build:host,dotnet:host,msbuild:host`
---
## Reference Paths
| Item | Path / Value |
| ------------------------- | ------------------------------------------------------------ |
| vmrun.exe | `C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe` |
| act_runner exe | `F:\CI\act_runner\act_runner.exe` (servizio: `act_runner`) |
| act_runner config | `F:\CI\act_runner\config.yaml` |
| act_runner logs | `F:\CI\act_runner\logs\` (stdout.log, stderr.log) |
| Template VMX | `F:\CI\Templates\WinBuild\CI-WinBuild.vmx` |
| Snapshot name | `BaseClean` |
| Clone base dir | `F:\CI\BuildVMs\` |
| Artifact dir | `F:\CI\Artifacts\` |
| Log dir | `F:\CI\Logs\` (retention: 30 giorni) |
| Gitea URL (LAN) | `http://10.10.20.11:3100` |
| Gitea URL (ext) | `https://gitea.emulab.it` |
| Runner name | `local-windows-runner` (ID: 1) |
| Build VM subnet | `192.168.79.0/24` (VMnet8 — NAT, internet access per build) |
| Credential Manager target | `BuildVMGuest` |
| Guest username | `ci_build` |
---
## Maintenance & operational tasks
Voci di backlog (vedi [TODO.md](../TODO.md)):
- **§2.2** Cleanup VM orfane schedulato (`scripts/Cleanup-OrphanedBuildVMs.ps1` ogni 6h, `-MaxAgeHours 4`)
- **§2.3** Retention artifact + log (Artifacts > 30 gg, Logs > 30 gg, guard libero < 50 GB)
- **§2.7** Health check runner (query API Gitea ogni 15 min, restart su offline)
- **§4.3** Disk space alert (`F:` < 50 GB → eventcreate/webhook)
- **§1.2** Restringere `TrustedHosts` da `*` a `192.168.79.*`
---
## Troubleshooting
| Sintomo | Diagnosi / Fix |
| --------------------------------------- | -------------------------------------------------------------------- |
| `vmrun.exe NOT found` | Installa VMware Workstation Pro, riesegui |
| `NSSM not available` | Install manuale da https://nssm.cc, aggiungi a PATH, riesegui |
| Runner registration `exit != 0` | Verifica token Gitea + URL raggiungibile da host |
| Service `act_runner` non parte | Check `F:\CI\act_runner\logs\stderr.log` |
| `New-StoredCredential` fail | Verifica modulo CredentialManager importato + scope corretto |
| Job CI fallisce su `Test-WSMan` | TrustedHosts host non include subnet VM, AllowUnencrypted=$false |
+346
View File
@@ -0,0 +1,346 @@
# Linux Template VM Setup — DRAFT / TODO
> **Status**: bozza. Non implementato. Vedi [TODO.md §6.1](../TODO.md) (P2).
> Documento di pianificazione per estendere il sistema CI con build su Linux,
> in parallelo al template Windows già operativo.
---
## Obiettivo
Aggiungere supporto a build su Linux per:
- Compilazione cross-platform (es. versione Linux di plugin C/C++, build check con GCC/Clang)
- Test su distro Linux per progetti che lo richiedono
- Build matrix `[windows, linux]` nei workflow Gitea
Architettura simmetrica al template Windows: VM template → snapshot `BaseClean-Linux`
linked clone per ogni job → cleanup automatico.
---
## Decisioni di design (da validare)
### Distro
| Opzione | Pro | Contro |
| ------------------------ | ------------------------------------------------------------------ | -------------------------------------------- |
| **Ubuntu 24.04 LTS** | Toolchain recente, supporto LTS fino al 2029, cloud-init ottimo | Snap default (lento per CI) |
| Debian 12 | Stabile, leggero, no snap | Toolchain meno recente |
| Rocky Linux 9 / Alma 9 | RHEL-compat, glibc moderno, dnf | Meno comune in workflow CI tipici |
**Scelta proposta**: Ubuntu 24.04 LTS minimal. Rationale: maggior parità con runner GitHub
ufficiali (`ubuntu-latest` mappa a 22.04/24.04), toolchain recente out-of-box, cloud-init
maturo per provisioning automatico.
### Transport host → VM
| Opzione | Pro | Contro |
| ------------------------ | ------------------------------------------------------------------ | -------------------------------------------- |
| **SSH (chiave pubblica)**| Standard Linux, supporto nativo OpenSSH su Win11/Server 2025 | Diversa API rispetto WinRM (PSSession) |
| WinRM su Linux (PSCore) | Codice script unificato | Setup complesso, non standard, fragile |
**Scelta proposta**: SSH key-based con `ssh.exe` + `scp.exe` (built-in Windows 10+) o
modulo `Posh-SSH`. WinRM su Linux non è production-ready.
### Layer di astrazione
Aggiungere `scripts/_Transport.psm1` con interfaccia comune:
```powershell
# Pseudo-API
function Invoke-RemoteCommand {
param($Target, $ScriptBlock, $Args, [ValidateSet('WinRM','SSH')] $Transport)
}
function Copy-RemoteItem {
param($Source, $Destination, $Target, $Direction, $Transport)
}
function Test-RemoteReady {
param($Target, $TimeoutSec, $Transport)
}
```
Implementazioni:
- **WinRM** wrapper: `New-PSSession` + `Invoke-Command` + `Copy-Item -ToSession`
- **SSH** wrapper: `ssh.exe` + `scp.exe` con `-i $keyfile -o StrictHostKeyChecking=accept-new`
`Invoke-CIJob.ps1` riceve `-GuestOS Windows|Linux` (o auto-detect dal VMX `guestOS`),
sceglie transport.
---
## Specifiche VM proposte
| Parametro | Valore |
| ----------------------- | --------------------------------------------------------------- |
| OS | Ubuntu Server 24.04 LTS minimal |
| vCPU | 4 |
| RAM | 4096 MB (4 GB) — meno di Windows, footprint Linux più snello |
| Disco | 40 GB thin |
| NIC | VMnet8 (NAT) — internet per `apt` |
| VMX path | `F:\CI\Templates\LinuxBuild\LinuxBuild.vmx` |
| ISO | `ubuntu-24.04.x-live-server-amd64.iso` (in `F:\CI\ISO\`) |
| Snapshot name | `BaseClean-Linux` |
---
## TODO — Implementazione
### Pre-requisiti host
- [ ] **OpenSSH client** verificato presente su host (default Win11/Server 2025)
```powershell
Get-WindowsCapability -Online -Name OpenSSH.Client*
```
- [ ] **Genera SSH key dedicata** per CI (no passphrase, separata da chiavi utente):
```powershell
ssh-keygen -t ed25519 -f F:\CI\keys\ci_linux -N '""' -C 'ci-linux-runner'
```
- [ ] **Aggiungere a `Setup-Host.ps1`** uno step opzionale `-GenerateLinuxCIKey` che crea
la chiave e la archivia con permessi corretti (`F:\CI\keys\` con ACL ristretta)
### Fase A — Crea VM e installa Ubuntu
- [ ] Crea nuova VM in VMware Workstation (4 vCPU, 4 GB RAM, 40 GB thin)
- [ ] NIC: VMnet8 (NAT)
- [ ] Boot da ISO Ubuntu Server 24.04 LTS minimal
- [ ] Durante install scegli:
- [ ] **Minimal install** (no extra packages)
- [ ] OpenSSH server: **Yes**
- [ ] Username: `ci_build`
- [ ] Hostname: `ci-linux-template`
- [ ] LVM: opzionale (non necessario per template)
- [ ] Annota IP DHCP (`ip a`)
### Fase B — Provisioning iniziale (manuale, una volta)
Dentro la VM, come `ci_build`:
- [ ] **Disabilita swap permanente** (overhead I/O su NVMe condivisa):
```bash
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
```
- [ ] **Disabilita servizi non necessari** (snap, motd-news, ecc.):
```bash
sudo systemctl disable --now snapd.service snapd.socket snapd.seeded.service 2>/dev/null
sudo systemctl disable --now motd-news.timer 2>/dev/null
sudo systemctl disable --now apt-daily.timer apt-daily-upgrade.timer
```
Rationale: snap e timer apt rallentano boot e mangiano CPU su VM efimere.
- [ ] **Sudo passwordless** per `ci_build` (parità con WinRM admin senza prompt UAC):
```bash
echo 'ci_build ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/90-ci_build
sudo chmod 440 /etc/sudoers.d/90-ci_build
```
- [ ] **Authorized SSH key** dell'host CI:
```bash
mkdir -p ~/.ssh && chmod 700 ~/.ssh
# Incolla la chiave pubblica generata su host (F:\CI\keys\ci_linux.pub)
echo 'ssh-ed25519 AAAA... ci-linux-runner' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
```
- [ ] **SSH hardening** per template (ma non così stretto da bloccare CI):
```bash
sudo sed -i 's/^#*PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart sshd
```
### Fase C — Setup-LinuxTemplateVM.sh (script equivalente a Setup-WinBuild2025.ps1)
Da creare: `template/Setup-LinuxTemplateVM.sh` con `set -euo pipefail` + `assert_step()` helper.
- [ ] **Helper `assert_step`** (throw on fail, log su success):
```bash
assert_step() {
local step="$1"; local desc="$2"; shift 2
if "$@" >/dev/null 2>&1; then
echo " [OK] [$step] $desc"
else
echo " [FAIL] [$step] $desc" >&2; exit 1
fi
}
```
- [ ] **Step 1 — `apt update` + upgrade**
```bash
sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq
assert_step 'apt' 'no errors' true
```
- [ ] **Step 2 — Toolchain build essentials**
```bash
sudo apt-get install -y -qq \
build-essential gcc g++ clang \
make cmake ninja-build \
pkg-config autoconf automake libtool \
git curl wget ca-certificates \
python3 python3-pip python3-venv \
unzip zip xz-utils
assert_step 'toolchain' 'gcc presente' command -v gcc
assert_step 'toolchain' 'clang presente' command -v clang
assert_step 'toolchain' 'cmake presente' command -v cmake
assert_step 'toolchain' 'python3 presente' command -v python3
```
- [ ] **Step 3 — Toolchain extra opzionale**
- [ ] **mingw-w64** se serve cross-compile per Windows: `sudo apt-get install -y mingw-w64`
- [ ] **.NET SDK** (per parità Windows): script `dotnet-install.sh` con channel pinned
- [ ] **Node.js** se workflow lo richiede: nvm o nodesource repo
- [ ] **Step 4 — CI working directories**
```bash
sudo mkdir -p /opt/ci/{build,output,scripts,cache}
sudo chown -R ci_build:ci_build /opt/ci
assert_step 'dirs' 'tutte presenti' test -d /opt/ci/build -a -d /opt/ci/output
```
- [ ] **Step 5 — Disabilita auto-update apt** (no surprise reboot/lock):
```bash
sudo apt-get remove -y unattended-upgrades 2>/dev/null
sudo systemctl mask apt-daily.service apt-daily-upgrade.service
```
- [ ] **Step 6 — Cleanup pre-snapshot**
```bash
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
sudo journalctl --vacuum-time=1d
sudo rm -rf /tmp/* /var/tmp/*
history -c && cat /dev/null > ~/.bash_history
# Zero free space (riduce dim VMDK dopo compact, opzionale)
# sudo dd if=/dev/zero of=/zero bs=1M; sudo rm /zero
```
- [ ] **Step 7 — Final pre-snapshot validation**
- SSH server abilitato e in ascolto su 22
- `sudo` passwordless funzionante per `ci_build`
- Tutti i tool elencati (`gcc --version`, `cmake --version`, ecc.) ritornano exit 0
- `/opt/ci/{build,output,scripts}` esistono con owner corretto
- No leftover `/tmp/*`, history vuota
### Fase D — Prepare-LinuxTemplateSetup.ps1 (orchestratore host-side)
Equivalente di `Prepare-WinBuild2025.ps1` per Linux. Usa SSH invece di WinRM.
- [ ] **Pre-flight host**:
- SSH key esiste (`F:\CI\keys\ci_linux`)
- `ssh.exe` e `scp.exe` raggiungibili
- IP target risponde a `Test-NetConnection -Port 22`
- [ ] **Copia script in VM**:
```powershell
scp -i F:\CI\keys\ci_linux -o StrictHostKeyChecking=accept-new `
template\Setup-LinuxTemplateVM.sh ci_build@${VMIPAddress}:/tmp/
```
- [ ] **Esegui script in VM**:
```powershell
ssh -i F:\CI\keys\ci_linux ci_build@${VMIPAddress} `
"chmod +x /tmp/Setup-LinuxTemplateVM.sh && sudo /tmp/Setup-LinuxTemplateVM.sh"
```
Cattura stdout + exit code, parse `[OK]`/`[FAIL]` lines.
- [ ] **Post-setup remote validation** (parità Windows): query SSH per state checks:
- `systemctl is-active ssh` = active
- `id ci_build` ritorna gruppo + sudo
- `gcc --version`, `cmake --version`, `python3 --version` exit 0
- `test -d /opt/ci/build` exit 0
### Fase E — Snapshot
- [ ] Solo se Prepare exit 0 → power off VM:
```bash
sudo shutdown -h now
```
- [ ] Take snapshot in VMware Workstation: **`BaseClean-Linux`** (case-sensitive)
### Fase F — Adattamento script CI
- [ ] **`scripts/_Transport.psm1`** (nuovo modulo) con interfaccia comune
- [ ] **`scripts/Invoke-CIJob.ps1`** — aggiungere `-GuestOS Windows|Linux`, branching su transport
- [ ] **`scripts/Wait-VMReady.ps1`** — supporto SSH probe (`Test-NetConnection -Port 22` + `ssh -o ConnectTimeout=5 ...`)
- [ ] **`scripts/Invoke-RemoteBuild.ps1`** — branch SSH+scp per Linux
- [ ] **`scripts/Get-BuildArtifacts.ps1`** — `scp` per Linux invece di `Copy-Item -FromSession`
- [ ] **`scripts/New-BuildVM.ps1`** — already OS-agnostic (vmrun clone), verificare che `BaseClean-Linux` sia parametrizzabile
- [ ] **`scripts/Remove-BuildVM.ps1`** — already OS-agnostic
### Fase G — Workflow Gitea
- [ ] **Registrare label `linux-build`** sul runner (oppure secondo runner dedicato):
```yaml
# runner/config.yaml — aggiungere a labels esistenti
labels:
- 'windows-build:host'
- 'linux-build:host'
```
- [ ] **Workflow matrix**:
```yaml
jobs:
build:
strategy:
matrix:
target: [windows, linux]
runs-on: ${{ matrix.target }}-build
steps:
- uses: actions/checkout@v4
- run: pwsh -File scripts/Invoke-CIJob.ps1 -GuestOS ${{ matrix.target }}
```
### Fase H — Test e2e
- [ ] **Smoke test** clone + SSH ready:
```powershell
.\scripts\New-BuildVM.ps1 -JobId 'linux-smoke' -SnapshotName 'BaseClean-Linux'
.\scripts\Wait-VMReady.ps1 -JobId 'linux-smoke' -Transport SSH
.\scripts\Remove-BuildVM.ps1 -JobId 'linux-smoke'
```
- [ ] **Build reale**: scegliere repo con build Linux nativo (es. `cmake` based) e
eseguire job e2e via `Invoke-CIJob.ps1 -GuestOS Linux`
- [ ] **Confronto artifact**: build dello stesso repo target Windows vs Linux,
verifica che entrambi vengano raccolti in `F:\CI\Artifacts\<jobId>\`
---
## Differenze chiave rispetto al template Windows
| Aspetto | Windows | Linux |
| ----------------------- | -------------------------------------- | ------------------------------------------- |
| Transport | WinRM HTTPS/5986 + Basic (-SkipCACheck) | SSH/22 + ed25519 key |
| Auth fluid | `ci_build` admin + UAC off + LATFP=1 | `ci_build` + sudoers NOPASSWD |
| Trasferimento file | `Copy-Item -ToSession` | `scp` o `rsync` |
| Esecuzione remota | `Invoke-Command -Session` | `ssh user@host 'cmd'` |
| AV/Firewall disable | Defender + Win Firewall off | ufw inattivo (default), no AV |
| Update package | Windows Update (COM API + sched task) | `apt-get update && upgrade` non-interactive |
| Toolchain | VS Build Tools 2026 + .NET SDK | gcc/g++/clang + cmake (+ .NET SDK opt) |
| Snapshot name | `BaseClean` | `BaseClean-Linux` |
| Footprint VM | 6 GB RAM, 80 GB disk | 4 GB RAM, 40 GB disk |
| Boot time | ~30-60 s post-clone | ~10-20 s post-clone (atteso) |
---
## Backlog correlato (TODO.md)
- **§6.1 [P2]** Linux Build VM — entry parent
- **§6.4 [P3]** Build matrix `[windows, linux]` nel workflow
- **§6.3 [P3]** Multi-host runner federation (se 4 VM Linux + 4 Windows non bastano)
- **§5.2 [P2]** Modulo `_Transport.psm1` condiviso (prerequisito architetturale)
---
## Domande aperte
1. **.NET SDK su Linux** — sempre installato o solo se workflow lo richiede?
Decisione: installare **se** workload richiede parità di build. Default off, opt-in via flag.
2. **Image size** — Ubuntu minimal + toolchain ≈ 5 GB. Compact post-snapshot per ridurre footprint VMDK?
Decisione: sì, `dd if=/dev/zero` + `vmware-vdiskmanager -k` (manuale, una tantum).
3. **Pre-warm pool Linux** — boot Linux è veloce, beneficio minore. Decisione: implementare solo se profile dimostra > 30% overhead.
4. **Cloud-init vs script manuale** — cloud-init richiede ISO seed o NoCloud datasource. Più pulito ma overhead di setup. Decisione: script bash diretto (parità con flusso Windows). Cloud-init valutabile in v2.
+169
View File
@@ -0,0 +1,169 @@
# §7.4 — E2e test refactor Deploy↔Setup
Valida che il refactor §7 (2026-05-10) funzioni su VM reale:
Deploy possiede OS hardening → Setup valida senza re-applicare.
**Non toccare l'existing template** (`CI-WinBuild.vmx` + snapshot `BaseClean`).
Usare VM separata per i test.
---
## Prerequisiti
- `F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso` presente
- `F:\CI\ISO\VMware-tools-windows.iso` presente
- VMware Workstation aperto, sufficiente spazio su `F:\CI\Templates\WinBuildTest\`
---
## Step 1 — Deploy su VM test
```powershell
cd N:\Code\Workspace\Local-CI-CD-System\template
.\Deploy-WinBuild2025.ps1 `
-WinISO 'F:\CI\ISO\26100.1742.240906-0331.ge_release_svc_refresh_SERVER_VOL_x64FRE_en-us.iso' `
-ToolsISO 'F:\CI\ISO\VMware-tools-windows.iso' `
-VMXPath 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' `
-VMName 'WinBuild2025-test' `
-ProductKey 'TVRH6-WHNXV-R9WG3-9XRFY-MY832'
```
Durata attesa: 3090 min. Exit 0 = OK, snapshot `PostInstall` preso automaticamente.
Se timeout su `install_complete.flag`: aumentare `-GuestPollMaxMinutes 120`.
---
## Step 2 — Verifica stato Deploy (§7.1 + §7.2)
Sostituire `192.168.79.xxx` con IP reale della VM test (`ipconfig` dentro la VM).
```powershell
$ip = '192.168.79.xxx'
$cred = Get-Credential # Administrator / WinBuild!
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
Invoke-Command -ComputerName $ip -Credential $cred `
-Authentication Basic -SessionOption $so -ScriptBlock {
"=== §7.1 Firewall ==="
Get-NetFirewallProfile | Select-Object Name, Enabled
"=== §7.2 WU services ==="
Get-Service wuauserv, UsoSvc | Select-Object Name, StartType, Status
"=== §7.2 WU GPO ==="
$au = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU'
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'
[PSCustomObject]@{
NoAutoUpdate = (Get-ItemProperty $au -EA SilentlyContinue).NoAutoUpdate
DisableWindowsUpdateAccess = (Get-ItemProperty $wu -EA SilentlyContinue).DisableWindowsUpdateAccess
}
"=== §7.1 WinRM limits ==="
[PSCustomObject]@{
MaxMemoryPerShellMB = (Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB).Value
IdleTimeOut = (Get-Item WSMan:\localhost\Shell\IdleTimeOut).Value
}
"=== §7.1 UAC ==="
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' |
Select-Object EnableLUA, LocalAccountTokenFilterPolicy
}
```
**Atteso**:
| Check | Valore atteso |
|---|---|
| Firewall tutti profili | `Enabled=False` |
| `wuauserv` StartType | `Manual` |
| `UsoSvc` StartType | `Manual` |
| `NoAutoUpdate` | `1` |
| `DisableWindowsUpdateAccess` | `1` |
| `MaxMemoryPerShellMB` | `>= 2048` |
| `EnableLUA` | `0` |
---
## Step 3 — Prepare -SkipWindowsUpdate (Assert-Step validation)
```powershell
.\Prepare-WinBuild2025.ps1 `
-VMIPAddress 192.168.79.xxx `
-SkipWindowsUpdate
```
**Cosa verificare nel log**:
- Step 1 (Firewall): solo `[OK]` — nessun `Set-NetFirewallProfile`
- Step 3 (WinRM): solo `[OK]` — nessun `Enable-PSRemoting``winrm set`
- Step 4b (UAC): solo `[OK]` — nessun `Set-ItemProperty EnableLUA`
- Step 5b (Explorer): solo `[OK]`
- Step 5c (CIUX): solo `[OK]`
- Step 4 (user `ci_build`): crea utente — operativo, non validation
- Step 5 (CI dirs): crea `C:\CI\{build,output,scripts}` — operativo
- Step 6: `[Setup] Skipping Windows Update` — corretto con `-SkipWindowsUpdate`
- Step 6b: disabilita wuauserv/UsoSvc — deve girare sempre
- Step 7-10: .NET, Python, VS Build Tools installati
- Final gate: tutti e 7 i check `[OK]`
- Exit code: `0`
Se un Assert-Step fallisce con `[FAIL]` su Step 1/3/4b/5b/5c → Deploy non ha applicato
quel setting → debug Deploy post-install.ps1 (controllare log nella VM).
---
## Step 4 — Prepare senza -Skip (WU lifecycle §7.2)
> Solo se Step 3 è passato. La VM deve avere internet (VMnet8 NAT).
```powershell
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.xxx
```
**Atteso**:
- Step 6 Step A: rimuove `DisableWindowsUpdateAccess` e `NoAutoUpdate` GPO keys
- Step 6 Step D: avvia wuauserv/UsoSvc/bits/cryptsvc (già Manual → solo Start)
- Step 6: WU gira via SchTask SYSTEM, ResultCode ∈ {0, 2, 3}
- Step 6b: `wuauserv StartType=Disabled`, `UsoSvc StartType=Disabled`, GPO keys re-scritti
- Final gate `Windows Update permanently disabled``[OK]`
- Exit code: `0` (o `3010` se WU richiede reboot — ripetere con `-SkipWindowsUpdate`)
---
## Step 5 — Snapshot e promozione (opzionale)
Se tutti i check di Step 3 e 4 passano:
```powershell
# Spegni VM test
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
-T ws stop 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' soft
# Snapshot versionato
& 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' `
-T ws snapshot 'F:\CI\Templates\WinBuildTest\CI-WinBuild-test.vmx' `
"BaseClean_$(Get-Date -Format yyyyMMdd)"
```
Per promuovere a produzione (rimpiazza template esistente):
1. Copia `F:\CI\Templates\WinBuildTest\``F:\CI\Templates\WinBuild\` (backup prima)
2. Aggiorna `GITEA_CI_TEMPLATE_PATH` in `runner/config.yaml` se path cambia
3. Aggiorna `GITEA_CI_SNAPSHOT_NAME` se usi nome versionato (vedi TODO §2.5)
---
## Checklist §7.4 — COMPLETATA 2026-05-10
- [x] Step 1: Deploy exit 0, snapshot `PostInstall` presente
- [x] Step 2: Firewall=False, wuauserv=Manual, NoAutoUpdate=1, MaxMemory≥2048, UAC=0
(UsoSvc era Automatic — fix applicato in Deploy; corretto manualmente sulla VM test)
- [x] Step 3: Prepare `-SkipWindowsUpdate` exit 0, nessun Set in Step 1/3/4b/5b/5c
- [x] Step 4: Prepare senza `-Skip` exit 0 (WU: 0 update, ResultCode=0), wuauserv=Disabled post-Step 6b
- [x] Snapshot `BaseClean` preso con successo
**Fix applicati durante il test**:
- Deploy: `UsoSvc` esplicitamente a `Manual` (era omesso, default Server 2025 = Automatic)
- Setup cleanup: ri-applica `Disabled` su wuauserv/UsoSvc dopo DISM (WaaSMedicSvc resets StartType)
- Deploy + Setup 5c: autologin Administrator (`AutoAdminLogon=1`, `DefaultUserName`, `DefaultPassword`, `DefaultDomainName`)
- Nuovi: `Validate-DeployState.ps1`, `Validate-SetupState.ps1`
+311
View File
@@ -0,0 +1,311 @@
# Windows Template VM Setup
Procedura di provisioning della template VM **Windows Server 2025** che alimenta i linked
clone usati per ogni job CI. Il template viene creato **una volta**; lo snapshot
`BaseClean` è la base immutabile per tutte le build successive.
**Prerequisito**: aver eseguito [HOST-SETUP.md](HOST-SETUP.md) (`F:\CI\` esiste,
credenziali archiviate, ISO Windows Server presente).
---
## Architettura: tre script
| Script | Dove gira | Scopo |
| ---------------------------------------- | --------------- | ------------------------------------------------------------------- |
| `template/Deploy-WinBuild2025.ps1` | **Host** | Opzionale: crea VM e installa Windows unattended da ISO |
| `template/Prepare-WinBuild2025.ps1` | **Host** | Orchestratore: copia + esegue Setup nel guest via WinRM |
| `template/Setup-WinBuild2025.ps1` | **Dentro VM** | CI toolchain (user, .NET, Python, VS) + validazione hardening Deploy |
| `template/Validate-DeployState.ps1` | **Host** | Valida stato post-Deploy prima di eseguire Prepare |
| `template/Validate-SetupState.ps1` | **Host** | Valida stato post-Setup (Deploy + Setup) prima dello snapshot |
**Confine Deploy / Setup** (refactor §7, 2026-05-10):
- `Deploy-WinBuild2025.ps1` si occupa dell'**OS hardening** (Firewall, WinRM, UAC, Defender, UX tweaks, autologin Administrator, WU GPO locks). Se usato, Setup valida questi come Assert-Step senza re-applicarli.
- `Setup-WinBuild2025.ps1` si occupa della **CI customization** (utente `ci_build`, cartelle `C:\CI`, toolchain .NET/Python/VS, Windows Update lifecycle, cleanup, final gate).
`Prepare-WinBuild2025.ps1` apre una WinRM session, copia `Setup-WinBuild2025.ps1` in
`C:\CI\`, lo esegue, raccoglie l'exit code, valida lo stato del guest.
---
## Specifiche VM
| Parametro | Valore |
| ----------------------- | --------------------------------------------------------------- |
| OS | Windows Server 2025 Standard/Datacenter (Core o Desktop OK) |
| vCPU | 4 |
| RAM | 6144 MB (6 GB) |
| Disco | 80 GB thin provisioned |
| NIC | **VMnet8 (NAT)** — internet richiesto per WU + installer |
| VMX path | `F:\CI\Templates\WinBuild\CI-WinBuild.vmx` |
| ISO | Windows Server 2025 (da `F:\CI\ISO\`) |
---
## Fase A — Crea la VM e installa Windows
### A.1 Crea VM in VMware Workstation
- File → New Virtual Machine → Custom
- Hardware: 4 vCPU, 6144 MB, 80 GB thin
- Network: **VMnet8 (NAT)**
- VMX: `F:\CI\Templates\WinBuild\CI-WinBuild.vmx`
- CD/DVD: monta ISO Windows Server 2025
### A.2 Installa Windows Server 2025
- Edition: Standard o Datacenter (Server Core o con Desktop)
- Activation: vedi A.4 (deve essere fatto prima dello snapshot)
### A.3 Abilita WinRM dentro la VM
Dentro la VM, come Administrator:
```cmd
winrm quickconfig -q
```
```powershell
Enable-PSRemoting -Force -SkipNetworkProfileCheck
Set-Item WSMan:\localhost\Service\Auth\Basic $true -Force
# AllowUnencrypted left false — Deploy post-install.ps1 creates HTTPS/5986 listener
# Basic auth is secure over HTTPS; host connects on port 5986 with -SkipCACheck
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
```
### A.4 Attiva Windows Server 2025
**IMPORTANTE**: deve essere fatto **prima** dello snapshot. I linked clone ereditano
l'attivazione.
```cmd
slmgr /dli
```
Se non attivato, scegli il metodo:
- **KMS** (server in rete): `slmgr /skms <IP_KMS>``slmgr /ato`
- **MAK/Retail key**: `slmgr /ipk XXXXX-XXXXX-XXXXX-XXXXX-XXXXX``slmgr /ato`
- **KMS pubblico** (lab): vedi https://github.com/robin113x/Windows-Server-Activation/
Verifica: `slmgr /xpr` — deve mostrare attivazione permanente o scadenza lontana.
### A.5 Annota IP DHCP
Dentro la VM:
```cmd
ipconfig
```
Annota l'IP `192.168.79.x` assegnato dal DHCP di VMnet8.
---
## Fase B — Provisioning automatico dall'host
Dall'host (PowerShell elevato):
```powershell
cd N:\Code\Workspace\Local-CI-CD-System\template
# Provisioning completo + registrazione credenziali automatica:
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.128 -StoreCredential
# Se WU già applicato in precedenza:
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.128 -SkipWindowsUpdate -StoreCredential
```
### Cosa fa `Prepare-WinBuild2025.ps1` — passo per passo
Ogni step ha validazione `Assert-Step` (throw on failure, `[OK]` su pass):
1. **Pre-flight**: script dir presente, `Setup-WinBuild2025.ps1` presente, IP ottetti 0-255, TCP/5986 reachable
2. **Configura host WinRM client**: aggiunge `$VMIPAddress` ai `TrustedHosts` (no `AllowUnencrypted` — HTTPS non lo richiede)
- Salva valore precedente, ripristinato nel `finally` (host posture invariata)
3. **Validazione host WinRM**: `TrustedHosts` include IP
4. **Test connettività**: `Test-WSMan -Port 5986 -UseSSL -Authentication Basic` con credenziali admin
5. **Apre PSSession** `-UseSSL -Port 5986` verso la VM
6. **Crea `C:\CI`** in guest, valida creazione
7. **Copia `Setup-WinBuild2025.ps1`** in `C:\CI\Setup-WinBuild2025.ps1`, valida size match
8. **Esegue `Setup-WinBuild2025.ps1`** dentro la VM (vedi Fase C)
9. **Gestisce reboot 3010**: se WU richiede reboot, riavvia VM e suggerisce re-run con `-SkipWindowsUpdate`
10. **Post-setup remote validation** (9 check via singola `Invoke-Command`):
- WinRM Running
- User `$BuildUsername` exists + admin
- UAC `EnableLUA=0`
- Firewall tutti profili Off
- .NET SDK installato
- Python `C:\Python\python.exe` presente
- MSBuild path presente
- `C:\CI\{build,output,scripts}` presenti
11. **Restore host WinRM** nel `finally` (sempre eseguito, anche su failure)
### Parametri principali
| Parametro | Default | Note |
| --------------------- | -------------- | ----------------------------------------------------------------------------- |
| `-VMIPAddress` | (mandatory) | IP DHCP della VM su VMnet8 |
| `-AdminUsername` | Administrator | Account admin built-in nella VM |
| `-AdminPassword` | (prompt) | Se vuoto, prompt SecureString |
| `-BuildPassword` | (prompt) | Password per `ci_build` — mai hard-codare |
| `-BuildUsername` | ci_build | Account CI dentro la VM |
| `-DotNetSdkVersion` | 10.0 | Channel .NET SDK |
| `-SkipWindowsUpdate` | (off) | Pass-through a Setup-WinBuild2025.ps1 |
| `-StoreCredential` | (off) | Registra `BuildUsername`/`BuildPassword` in Windows Credential Manager |
| `-CredentialTarget` | BuildVMGuest | Target Credential Manager (deve corrispondere a quanto usato da Invoke-CIJob) |
---
## Fase C — Cosa fa `Setup-WinBuild2025.ps1` (dentro la VM)
Eseguito automaticamente da Prepare in Fase B. Steps 1/2/3/4b/5b/5c sono
**validation-only** — verificano che Deploy abbia già applicato l'hardening OS.
Steps 4/5/6/6b/7-10 eseguono la CI customization effettiva.
Ogni step ha validazione `Assert-Step` (throw on failure).
```
Step 1 Firewall: validation only — Deploy disabled all profiles [Assert-Step]
Step 2 Defender: validation only — Deploy set DisableAntiSpyware GPO + RTP off [Assert-Step]
Step 3 WinRM: validation only — Deploy set Basic/Unencrypted/MaxMem/Timeout [Assert-Step]
Step 4 Build user account (ci_build, admin, no expire) ── crea utente CI
Step 4b UAC: validation only — Deploy set EnableLUA=0 + TokenFilterPolicy [Assert-Step]
Step 5 CI dirs: C:\CI\{build, output, scripts} ── C:\CI deve esistere prima di WU
Step 5b Explorer LaunchTo=1: validation only — Deploy set HKCU + Default hive [Assert-Step]
Step 5c CI UX hardening: validation only — Deploy set lock screen, SM policy/task,
DisableCAD, OOBE, telemetry, autologin Administrator [Assert-Step]
Step 6 Windows Update via Scheduled Task SYSTEM ── clears Deploy GPO locks, runs WU
Step 6b Windows Update permanently disabled (post-update lockdown)
Step 7 .NET SDK install (channel via dotnet-install.ps1)
Step 8 Python install (3.13.3, all-users, C:\Python)
Step 9 VS Build Tools 2026 via Scheduled Task SYSTEM
Step 10 Toolchain cross-check (dotnet, python, msbuild)
Cleanup cleanmgr /sagerun:1 + DISM /StartComponentCleanup /ResetBase
Final Pre-snapshot gate — 7 check cross-cutting + no installer leftover
```
### Razionale dell'ordine
- **Steps 1-3 (validazioni)** — verificano prima le precondizioni Deploy (firewall off,
Defender off, WinRM pronto) in modo che i failure emergano subito, non a metà toolchain.
- **User + UAC (4/4b)** — crea `ci_build`; valida UAC off (set da Deploy); UAC off prima
di dir/tool evita prompt di elevazione negli step 7-9.
- **CI dirs prima di WU (Step 5)** — il worker WU scrive file temp in `C:\CI\`
(`wu_worker.ps1`, `wu_result.txt`, ecc.); la directory deve esistere prima.
- **UI tweaks (5b/5c)** — validazioni registro rapide; raggruppate prima di WU (lento).
- **WU (Step 6)** — clears i GPO lock di Deploy (Step A), avvia COM API via SYSTEM task,
poi Step 6b re-disabilita tutto → snapshot cattura WU permanentemente off.
- **Toolchain (7-9) last** — WU completato, nessun scan su payload installer.
### Validazioni per step (sintesi)
| Step | Natura | Check chiave |
| ---- | ---------- | ------------------------------------------------------------------------------ |
| 1 | Assert | `Get-NetFirewallProfile -Profile Domain/Private/Public → Enabled=False` |
| 2 | Assert | GPO DisableAntiSpyware=1 (set da Deploy) |
| 3 | Assert | WinRM Running+Automatic, AllowUnencrypted=false (HTTPS-only), Auth/Basic=true, MaxMem≥2048 |
| 4 | Operativo | User exists, enabled, PasswordNeverExpires, member of Administrators |
| 4b | Assert | EnableLUA=0, LocalAccountTokenFilterPolicy=1 |
| 5 | Operativo | Ogni dir Test-Path -PathType Container |
| 5b | Assert | HKCU LaunchTo=1 |
| 5c | Assert+Op | NoLockScreen=1, SM policy DoNotOpenAtLogon=1, SM task Disabled, DisableCAD=1, OOBE DisablePrivacyExperience=1, AllowTelemetry=0; AutoAdminLogon=1+DefaultUserName=Administrator re-affermati se assenti (Windows OOBE può resettarli al primo boot) |
| 6 | Operativo | ResultCode ∈ {0,2,3}, no reboot required (else exit 3010) |
| 6b | Operativo | wuauserv StartType=Disabled, NoAutoUpdate=1, DisableWindowsUpdateAccess=1 |
| 7 | Operativo | `dotnet --version` channel match, machine PATH contiene `C:\dotnet` |
| 8 | Operativo | `C:\Python\python.exe` esiste, version match esatto, PATH contiene `C:\Python` |
| 9 | Operativo | MSBuild.exe esiste, esegue, PATH, `\VC\` dir presente |
| 10 | Operativo | dotnet+python+msbuild tutti risolvibili (throw) |
| Final| Cross-cut | WinRM, firewall, user+admin, UAC, WU disabled, tutti tool, no leftover installer in `C:\CI` |
---
## Fase D — Snapshot
**Solo se `Prepare-WinBuild2025.ps1` exit 0** (tutti gli `Assert-Step` passati):
1. **Spegni la VM**: Start → Shut down (graceful, non hard-stop)
2. **Take snapshot** in VMware Workstation:
- VM → Snapshot → Take Snapshot
- **Nome esatto: `BaseClean`** (case-sensitive, usato da `New-BuildVM.ps1`)
3. **Lascia la VM spenta** — non modificarla mai più dopo lo snapshot
4. **Verifica `runner/config.yaml`**: `GITEA_CI_TEMPLATE_PATH=F:\CI\Templates\WinBuild\CI-WinBuild.vmx`
---
## Validation scripts standalone
Due script per validare il guest in punti specifici del flusso, indipendentemente da
Prepare. Utili per debug, re-verifica dopo modifiche manuali, e CI automatizzata.
### Dopo Deploy — prima di Prepare
```powershell
.\template\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129
```
Controlla: Firewall off, Defender GPO, WinRM (Running/Automatic/AllowUnencrypted/Basic/MaxMem),
UAC off, Explorer LaunchTo, NoLockScreen, Server Manager (policy+HKLM+task+HKCU),
DisableCAD, OOBE, AllowTelemetry, **AutoAdminLogon=1**, WU GPO, wuauserv/UsoSvc=Manual.
### Dopo Prepare — prima di snapshot
```powershell
.\template\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129
```
Tutti i check Deploy + ci_build (exists/enabled/admin/PasswordNeverExpires),
`C:\CI\{build,output,scripts}`, wuauserv/UsoSvc=Disabled, WU GPO,
.NET SDK channel match, Python version match, MSBuild present, no leftover installer files.
Exit 0 = tutto OK. Exit 1 = almeno un check fallito (output indica quale).
---
## Fase E — Verifica finale
Dall'host:
```powershell
# Test creazione clone manuale
cd N:\Code\Workspace\Local-CI-CD-System\scripts
.\New-BuildVM.ps1 -JobId 'smoke-test'
# Verifica WinRM raggiungibile sul clone
.\Wait-VMReady.ps1 -JobId 'smoke-test' -TimeoutSec 120
# Cleanup
.\Remove-BuildVM.ps1 -JobId 'smoke-test'
```
Se i 3 step exit 0 → template pronto per produzione.
---
## Maintenance — refresh semestrale
Il template va rinfrescato ogni ~180 giorni (KMS lease) o quando serve aggiornare la
toolchain (.NET, VS Build Tools, Python).
**Procedura**:
1. Power on della template VM (NIC: VMnet8 NAT)
2. Riattiva: `slmgr /ato`
3. Re-run `Prepare-WinBuild2025.ps1` per applicare update toolchain (passare flag opportuni)
4. Shut down + nuovo snapshot **versionato**: `BaseClean_<yyyyMMdd>` (TODO §2.5)
5. Aggiorna `GITEA_CI_SNAPSHOT_NAME` env var in `runner/config.yaml`
6. Tieni i 2 snapshot più recenti, cancella vecchi dopo 1 settimana di uso pulito
---
## Troubleshooting
| Sintomo | Diagnosi / Fix |
| ------------------------------------------- | ----------------------------------------------------------------------- |
| `Cannot reach $VMIPAddress via WinRM` | VM non avviata, WinRM non abilitato, IP errato — verifica con ipconfig |
| `Setup-WinBuild2025.ps1 exited with code 3010`| Reboot richiesto post-WU. Re-run con `-SkipWindowsUpdate` |
| `[Defender] Validation failed: GPO key` | Defender riattivato da Windows Update — disabilita via gpedit + reboot |
| `VS Build Tools installation timed out` | Network slow o cache corrotta. Re-run con cache pulita (`C:\ProgramData\Microsoft\VisualStudio\Packages`) |
| `Python version match` fail | `$PythonVersion` ≠ versione installata. Aggiorna param o reinstalla |
| Post-setup `MSBuild not found` | VS install fallita silenziosa. Check `C:\CI\vs_log.txt` |
| Pre-snapshot Final check fail | Stato VM rotto — non prendere snapshot. Re-run o ricomincia da zero |
---
## Backlog correlato (TODO.md)
- **§1.1 [P0]** Migrare WinRM da HTTP/Basic a HTTPS/5986 con cert self-signed nel template
- **§1.3 [P0]** Pinning hash SHA256 degli installer (Python, dotnet-install.ps1, vs_buildtools.exe)
- **§1.6 [P2]** Documentare threat model per Defender/Firewall/UAC tutti off
- **§2.5 [P1]** Snapshot versionato `BaseClean_<yyyyMMdd>` (rollback in caso di refresh rotto)
- **§2.6 [P2]** Backup automatico VMDK template pre-snapshot
- **In-VM Git Clone** — installare Git for Windows + 7-Zip nel template per ottimizzare flusso
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: nsis-plugin-nsinnounp-${{ github.ref_name }}
path: F:\CI\Artifacts\${{ github.run_id }}\artifacts.zip
+55
View File
@@ -0,0 +1,55 @@
# PSScriptAnalyzer lint — runs on every push/PR that touches PowerShell files.
# Requires PSScriptAnalyzer installed on the runner host:
# Install-Module PSScriptAnalyzer -Scope AllUsers -Force
#
# Failures block the PR. Fix warnings with:
# Invoke-ScriptAnalyzer -Path scripts\ -Recurse -Fix
name: Lint (PSScriptAnalyzer)
on:
push:
paths:
- '**.ps1'
- '**.psm1'
pull_request:
paths:
- '**.ps1'
- '**.psm1'
jobs:
lint:
runs-on: windows-build
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run PSScriptAnalyzer
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) {
Write-Host "Installing PSScriptAnalyzer..."
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber
}
$paths = @('scripts', 'template', 'runner', 'Setup-Host.ps1')
$results = $paths | ForEach-Object {
if (Test-Path $_) {
Invoke-ScriptAnalyzer -Path $_ -Recurse -Severity Error,Warning
}
}
if ($results) {
$results | Format-Table -AutoSize
Write-Error "PSScriptAnalyzer found $($results.Count) issue(s). Fix before merging."
exit 1
}
else {
Write-Host "PSScriptAnalyzer: no issues found."
}
+6
View File
@@ -4,6 +4,12 @@
.SYNOPSIS
Downloads, registers, and installs act_runner as a Windows service.
.NOTES
DEPRECATED use Setup-Host.ps1 instead.
Setup-Host.ps1 handles the full host bootstrap including act_runner service
installation in a single idempotent script. Install-Runner.ps1 is kept for
reference but may drift from Setup-Host.ps1 over time.
.DESCRIPTION
1. Downloads the act_runner binary from the specified URL (or Gitea releases).
2. Registers the runner with the Gitea instance using a registration token.
+104
View File
@@ -0,0 +1,104 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Destroys ephemeral build VMs that were not cleaned up (e.g. after host power loss).
.DESCRIPTION
Scans F:\CI\BuildVMs\ for clone directories older than -MaxAgeHours.
For each stale directory, stops the VM (hard), deletes via vmrun deleteVM,
then removes the directory.
Run as a daily scheduled task or on host startup to prevent disk accumulation.
Safe to run while CI jobs are active only touches VMs older than MaxAgeHours.
.PARAMETER CloneBaseDir
Directory containing ephemeral VM clones. Default: F:\CI\BuildVMs
.PARAMETER MaxAgeHours
Age threshold in hours. Directories with LastWriteTime older than this are
treated as orphaned. Must exceed the longest expected build duration.
Default: 4 (runner.timeout is 2h 4h gives double margin)
.PARAMETER VmrunPath
Path to vmrun.exe.
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.PARAMETER WhatIf
List orphaned VMs without destroying them.
.EXAMPLE
# Dry run
.\Cleanup-OrphanedBuildVMs.ps1 -WhatIf
# Live cleanup (run elevated)
.\Cleanup-OrphanedBuildVMs.ps1
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string] $CloneBaseDir = 'F:\CI\BuildVMs',
[ValidateRange(1, 168)]
[int] $MaxAgeHours = 4,
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue' # Don't abort on individual VM errors
if (-not (Test-Path $CloneBaseDir)) {
Write-Host "[Cleanup] Clone base dir not found: $CloneBaseDir — nothing to do."
exit 0
}
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
Write-Warning "[Cleanup] vmrun.exe not found at: $VmrunPath"
Write-Warning " Skipping vmrun stop/delete — will attempt directory removal only."
}
$cutoff = (Get-Date).AddHours(-$MaxAgeHours)
$orphans = Get-ChildItem -Path $CloneBaseDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoff }
if (-not $orphans) {
Write-Host "[Cleanup] No orphaned VMs found (threshold: $MaxAgeHours h)."
exit 0
}
Write-Host "[Cleanup] Found $($orphans.Count) orphaned VM director$(if ($orphans.Count -eq 1) {'y'} else {'ies'}) older than $MaxAgeHours h."
foreach ($dir in $orphans) {
$vmx = Get-ChildItem -Path $dir.FullName -Filter '*.vmx' -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($PSCmdlet.ShouldProcess($dir.FullName, "Destroy orphaned VM")) {
Write-Host "[Cleanup] Processing: $($dir.FullName)"
if ($vmx -and (Test-Path $VmrunPath -PathType Leaf)) {
# Hard stop — don't wait for graceful shutdown on an orphan
& $VmrunPath -T ws stop $vmx.FullName hard 2>&1 | Out-Null
Start-Sleep -Seconds 2
$deleteOut = & $VmrunPath -T ws deleteVM $vmx.FullName 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Warning "[Cleanup] vmrun deleteVM failed (exit $LASTEXITCODE): $deleteOut"
Write-Warning " Falling back to directory removal."
}
}
elseif (-not $vmx) {
Write-Warning "[Cleanup] No .vmx found in $($dir.FullName) — removing directory only."
}
Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path $dir.FullName) {
Write-Warning "[Cleanup] Could not fully remove: $($dir.FullName) — manual cleanup needed."
}
else {
Write-Host "[Cleanup] Removed: $($dir.FullName)"
}
}
else {
Write-Host "[Cleanup] WhatIf: would destroy $($dir.FullName) (age: $([int]((Get-Date) - $dir.LastWriteTime).TotalHours) h)"
}
}
Write-Host "[Cleanup] Done."
+8 -4
View File
@@ -39,7 +39,7 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
[string] $IPAddress,
[Parameter(Mandatory)]
@@ -70,6 +70,9 @@ $session = New-PSSession `
-ComputerName $IPAddress `
-Credential $Credential `
-SessionOption $sessionOptions `
-UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
try {
@@ -93,7 +96,7 @@ try {
# ── Copy logs if requested ────────────────────────────────────────────
if ($IncludeLogs) {
$logFiles = Invoke-Command -Session $session -ScriptBlock {
Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -ErrorAction SilentlyContinue |
Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -Recurse -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty FullName
}
@@ -109,10 +112,11 @@ try {
throw "Artifact was not found at expected host path after copy: $hostDestPath"
}
$sizeKB = [math]::Round((Get-Item $hostDestPath).Length / 1KB, 1)
if ($sizeKB -eq 0) {
$fileItem = Get-Item $hostDestPath
if ($fileItem.Length -eq 0) {
throw "Artifact file exists but is empty: $hostDestPath"
}
$sizeKB = [math]::Round($fileItem.Length / 1KB, 1)
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
return $hostDestPath
+12 -3
View File
@@ -103,7 +103,7 @@ param(
[string] $ArtifactBaseDir = 'F:\CI\Artifacts',
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
[string] $VMIPAddress = '', # if empty, auto-detected via vmrun getGuestIPAddress
[string] $GuestCredentialTarget = 'BuildVMGuest',
@@ -190,8 +190,9 @@ $exitCode = 0
try {
# ── Phase 1: Clone repo on HOST ───────────────────────────────────────
# The build VM has no internet/LAN access (Host-Only network).
# Source is cloned here on the host, then copied into the VM via WinRM.
# Build VM is on VMnet8 NAT (internet OK for builds), but source is
# still cloned here and copied via WinRM zip to avoid injecting a PAT
# into the VM environment.
Write-Host "`n[Phase 1/6] Cloning repository on host..."
if (Test-Path $hostCloneDir) { Remove-Item $hostCloneDir -Recurse -Force }
$gitArgs = @('clone', '--depth', '1', '--branch', $Branch)
@@ -208,6 +209,14 @@ try {
$ErrorActionPreference = 'Continue'
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
$gitExit = $LASTEXITCODE
if ($gitExit -ne 0) {
# Commit not in shallow history (e.g. manual run on older SHA).
# Fetch full history and retry.
Write-Host "[Invoke-CIJob] Shallow checkout failed — fetching full history..."
& git -C $hostCloneDir fetch --unshallow 2>&1 | Out-Null
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
$gitExit = $LASTEXITCODE
}
$ErrorActionPreference = 'Stop'
if ($gitExit -ne 0) {
throw "git checkout $Commit failed (exit $gitExit):`n$($gitOutput -join "`n")"
+10 -9
View File
@@ -49,7 +49,7 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
[string] $IPAddress,
[Parameter(Mandatory)]
@@ -88,21 +88,22 @@ $session = New-PSSession `
-ComputerName $IPAddress `
-Credential $Credential `
-SessionOption $sessionOptions `
-UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
try {
# ── Ensure working directories exist on guest ────────────────────────
Invoke-Command -Session $session -ScriptBlock {
param($workDir, $outputDir)
foreach ($dir in @($workDir, (Split-Path $outputDir -Parent))) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
}
# Clean previous build if present
if (Test-Path $workDir) {
Remove-Item $workDir -Recurse -Force
# Ensure artifact output parent exists
$outParent = Split-Path $outputDir -Parent
if (-not (Test-Path $outParent)) {
New-Item -ItemType Directory -Path $outParent -Force | Out-Null
}
# Clean and recreate build dir (ephemeral VM, but guard against partial prior run)
if (Test-Path $workDir) { Remove-Item $workDir -Recurse -Force }
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
} -ArgumentList $GuestWorkDir, $GuestArtifactZip
+26 -5
View File
@@ -72,16 +72,37 @@ $stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1
if ($stateOutput -match 'running') {
Write-Host "[Remove-BuildVM] Graceful stop timed out — forcing hard stop..."
& $VmrunPath -T ws stop $VMPath hard 2>&1 | Out-Null
Start-Sleep -Seconds 3
}
# ── Step 4: Delete VM via vmrun (removes VMX + delta VMDK) ───────────────────
# Poll vmrun list until VMX is gone — vmware-vmx.exe may hold file locks briefly
# after stop. Waiting for the entry to disappear ensures deleteVM won't race the
# process exit.
$vmxNorm = $VMPath.Trim().ToLower()
$releaseDeadline = (Get-Date).AddSeconds(20)
while ((Get-Date) -lt $releaseDeadline) {
$listedVMs = & $VmrunPath -T ws list 2>&1 |
Where-Object { $_ -notmatch '^Total running' } |
ForEach-Object { "$_".Trim().ToLower() }
if ($listedVMs -notcontains $vmxNorm) { break }
Start-Sleep -Seconds 2
}
# ── Step 4: Delete VM via vmrun — retry up to 3× with backoff ────────────────
Write-Host "[Remove-BuildVM] Deleting VM..."
$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1
$deleteExit = $LASTEXITCODE
$deleteOutput = ''
$deleteExit = -1
foreach ($delay in @(0, 3, 6)) {
if ($delay -gt 0) {
Write-Host "[Remove-BuildVM] Retrying deleteVM in ${delay}s..."
Start-Sleep -Seconds $delay
}
$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1
$deleteExit = $LASTEXITCODE
if ($deleteExit -eq 0) { break }
}
if ($deleteExit -ne 0) {
Write-Warning "[Remove-BuildVM] vmrun deleteVM returned exit code $deleteExit : $deleteOutput"
Write-Warning "[Remove-BuildVM] vmrun deleteVM failed after retries (exit $deleteExit): $deleteOutput"
Write-Warning "[Remove-BuildVM] Will attempt manual directory removal."
}
+145
View File
@@ -0,0 +1,145 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Runs a full end-to-end CI test build of nsis-plugin-nsinnounp.
.DESCRIPTION
Invokes Invoke-CIJob.ps1 with the nsinnounp repo parameters and
verifies the artifact was produced. Useful for smoke-testing the
CI pipeline after changes to scripts or template VM.
Exits 0 on success, 1 on failure.
.PARAMETER JobId
Job identifier used for artifact and log directory names.
Default: e2e-test-<yyyyMMdd_HHmmss>
.PARAMETER Branch
Branch to build. Default: main
.PARAMETER Commit
Optional specific commit SHA. Default: empty (HEAD of branch).
.PARAMETER TemplatePath
Path to template VMX. Default: env:GITEA_CI_TEMPLATE_PATH or
F:\CI\Templates\WinBuild\CI-WinBuild.vmx
.PARAMETER VerifyArtifact
If set, unzips artifacts.zip and verifies expected DLL/EXE names
are present. Requires 7-Zip or Expand-Archive.
.EXAMPLE
# Quick smoke test (HEAD of main):
.\Test-NsinnounpBuild.ps1
# Test a specific commit with artifact verification:
.\Test-NsinnounpBuild.ps1 -Commit abc1234 -VerifyArtifact
# Custom job ID to avoid colliding with a running e2e series:
.\Test-NsinnounpBuild.ps1 -JobId 'e2e-010'
#>
[CmdletBinding()]
param(
[string] $JobId = "e2e-test-$(Get-Date -Format 'yyyyMMdd_HHmmss')",
[string] $Branch = 'main',
[string] $Commit = '',
[string] $TemplatePath = $(
if ($env:GITEA_CI_TEMPLATE_PATH) { $env:GITEA_CI_TEMPLATE_PATH }
else { 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' }
),
[switch] $VerifyArtifact = $true
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
$orchestrator = Join-Path $scriptDir 'Invoke-CIJob.ps1'
$artifactDir = "F:\CI\Artifacts\$JobId"
$artifactZip = Join-Path $artifactDir 'artifacts.zip'
Write-Host "============================================================"
Write-Host " nsinnounp e2e test"
Write-Host " JobId : $JobId"
Write-Host " Branch : $Branch"
if ($Commit) { Write-Host " Commit : $Commit" }
Write-Host " Template : $TemplatePath"
Write-Host " Artifacts: $artifactDir"
Write-Host "============================================================"
Write-Host ""
$params = @{
JobId = $JobId
RepoUrl = 'ssh://gitea-ci/Simone/nsis-plugin-nsinnounp.git'
Branch = $Branch
TemplatePath = $TemplatePath
Submodules = $true
BuildCommand = 'python build_plugin.py --final --dist-dir dist'
GuestArtifactSource = 'dist'
GuestCredentialTarget = 'BuildVMGuest'
}
if ($Commit -ne '') { $params['Commit'] = $Commit }
$startTime = Get-Date
$exitCode = 0
try {
& $orchestrator @params
$exitCode = $LASTEXITCODE
}
catch {
Write-Host "[Test] Invoke-CIJob.ps1 threw: $_"
$exitCode = 1
}
$elapsed = (Get-Date) - $startTime
Write-Host ""
Write-Host "============================================================"
if ($exitCode -ne 0) {
Write-Host "[Test] FAIL — Invoke-CIJob.ps1 exited $exitCode after $($elapsed.ToString('mm\:ss'))" -ForegroundColor Red
exit 1
}
# Verify artifact zip exists and is non-empty
if (-not (Test-Path $artifactZip)) {
Write-Host "[Test] FAIL — artifact not found: $artifactZip" -ForegroundColor Red
exit 1
}
$zipSize = [math]::Round((Get-Item $artifactZip).Length / 1KB)
Write-Host "[Test] Artifact OK: $artifactZip ($zipSize KB)"
# Optional: expand and verify expected files are present
if ($VerifyArtifact) {
$extractDir = Join-Path $artifactDir 'verify'
Write-Host "[Test] Expanding artifact for verification..."
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
Expand-Archive -Path $artifactZip -DestinationPath $extractDir -Force
$expectedPatterns = @(
'*nsInnoUnp*.dll',
'*nsInnoUnpCLI*.exe'
)
$allFound = $true
foreach ($pattern in $expectedPatterns) {
$found = Get-ChildItem -Path $extractDir -Filter $pattern -Recurse -ErrorAction SilentlyContinue
if ($found) {
Write-Host "[Test] OK: $($found.Count) file(s) matching '$pattern'"
}
else {
Write-Host "[Test] MISSING: no files matching '$pattern'" -ForegroundColor Red
$allFound = $false
}
}
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
if (-not $allFound) {
Write-Host "[Test] FAIL — artifact missing expected files." -ForegroundColor Red
exit 1
}
}
Write-Host "[Test] PASS — $JobId completed in $($elapsed.ToString('mm\:ss'))" -ForegroundColor Green
Write-Host " Artifacts: $artifactDir"
Write-Host "============================================================"
exit 0
+13 -16
View File
@@ -40,7 +40,7 @@ param(
[string] $VMPath,
[Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
[string] $IPAddress,
[ValidateRange(30, 600)]
@@ -52,7 +52,7 @@ param(
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
# Skip ICMP ping phase (phase 2). Useful when the guest firewall blocks ICMP
# but WinRM (TCP 5985) is open. Phase 1 (vmrun state) and phase 3 (WinRM)
# but WinRM (TCP 5986) is open. Phase 1 (vmrun state) and phase 3 (WinRM)
# are still checked.
[switch] $SkipPing
)
@@ -75,15 +75,10 @@ while ((Get-Date) -lt $deadline) {
$attempt++
# ── Phase 1: VM must be running ─────────────────────────────────────
# vmrun has no getState; getGuestIPAddress fails with "not powered on"
# when the VM is off, and succeeds (exit 0) when running.
try {
# vmrun has no getState; getGuestIPAddress exits non-zero when the VM
# is not powered on. Native commands don't throw — check exit code only.
& $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null
$isRunning = ($LASTEXITCODE -eq 0)
}
catch {
$isRunning = $false
}
if (-not $isRunning) {
if ($phase -ne 'vmrun-state') {
@@ -112,18 +107,20 @@ while ((Get-Date) -lt $deadline) {
}
}
# ── Phase 3: WinRM responsive ────────────────────────────────────────
try {
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
# Success — VM is ready
$elapsed = ($deadline - (Get-Date)).Negate().TotalSeconds + $TimeoutSeconds
# ── Phase 3: WinRM port 5986 accepting TCP connections ───────────────
# Test-WSMan has no -SessionOption in PS 5.1 and fails cert validation for
# self-signed certs. TCP open on 5986 = HTTPS listener up = VM ready.
$winrmUp = Test-NetConnection -ComputerName $IPAddress -Port 5986 `
-InformationLevel Quiet -WarningAction SilentlyContinue `
-ErrorAction SilentlyContinue
if ($winrmUp) {
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
return
}
catch {
else {
$phase = 'winrm'
if ($lastPhase -ne $phase) {
Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5985..."
Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5986 (HTTPS)..."
$lastPhase = $phase
}
Start-Sleep -Seconds $PollIntervalSeconds
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-242
View File
@@ -1,242 +0,0 @@
#Requires -Version 5.1
<#
.SYNOPSIS
HOST-side script: delivers and runs Setup-TemplateVM.ps1 inside the template VM.
.DESCRIPTION
Automates the template VM provisioning from the host machine:
1. Verifies the VM is reachable via WinRM (must already be enabled in guest)
2. Copies Setup-TemplateVM.ps1 into the VM via WinRM
3. Runs Setup-TemplateVM.ps1 inside the VM with the given parameters
4. Handles the reboot-required case (exit 3010) and optionally re-runs
PREREQUISITES (do these manually before running this script):
a. VM NIC is set to VMnet8 (NAT) the VM needs internet for downloads
b. Windows Server is installed and booted
c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run:
winrm quickconfig -q
Enable-PSRemoting -Force -SkipNetworkProfileCheck
d. You know the IP address the VM got from DHCP on VMnet8 (NAT)
(check via: ipconfig inside the VM, or VMware VM Settings
Network Adapter Advanced MAC address, then check DHCP leases)
AFTER THIS SCRIPT COMPLETES:
1. Shut down the VM
2. Take snapshot: VM Snapshot Take Snapshot name: BaseClean
(VM stays on VMnet8 NAT internet access is required for builds)
3. Store credentials on host:
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password 'CIBuild!ChangeMe2026' -Persist LocalMachine
.PARAMETER VMIPAddress
IP address of the template VM while it is on NAT (VMnet8).
Example: 192.168.79.130
.PARAMETER AdminUsername
Administrator username inside the VM. Default: Administrator
.PARAMETER AdminPassword
Administrator password inside the VM. If not supplied, prompts interactively.
.PARAMETER BuildPassword
Password to set for the ci_build user inside the VM.
Default: CIBuild!ChangeMe2026 CHANGE THIS before production use.
.PARAMETER DotNetSdkVersion
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
.PARAMETER SkipWindowsUpdate
Pass through to Setup-TemplateVM.ps1 to skip the Windows Update step.
.EXAMPLE
# Interactive (prompts for admin password):
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130
# With explicit credentials:
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 `
-AdminPassword 'YourAdminPass' -BuildPassword 'StrongCIPass!2026'
# Skip Windows Update (already applied):
.\Prepare-TemplateSetup.ps1 -VMIPAddress 192.168.79.130 -SkipWindowsUpdate
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
[string] $VMIPAddress,
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword = '',
[string] $BuildPassword = 'CIBuild!ChangeMe2026',
[string] $DotNetSdkVersion = '10.0',
[switch] $SkipWindowsUpdate
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# ── Build PSCredential ────────────────────────────────────────────────────────
if ($AdminPassword -eq '') {
Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):"
$credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)"
}
else {
$secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd)
}
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
# ── Configure HOST-side WinRM client (required for Basic auth over HTTP) ──────
# These settings apply to this machine (the host), not the VM.
# AllowUnencrypted is needed for HTTP/5985 Basic auth in a lab environment.
Write-Host "[Prepare] Configuring host WinRM client for unencrypted Basic auth..."
try {
Set-Item WSMan:\localhost\Client\AllowUnencrypted $true -Force -ErrorAction Stop
# Add VM IP to TrustedHosts if not already present
$currentTrusted = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
if ($currentTrusted -ne '*' -and $currentTrusted -notlike "*$VMIPAddress*") {
$newTrusted = if ($currentTrusted -eq '') { $VMIPAddress } else { "$currentTrusted,$VMIPAddress" }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
}
Write-Host "[Prepare] Host WinRM client configured."
}
catch {
Write-Warning "[Prepare] Could not configure host WinRM client settings (need elevation?)."
Write-Warning "Run once in an elevated PowerShell on the HOST:"
Write-Warning " Set-Item WSMan:\localhost\Client\AllowUnencrypted `$true -Force"
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
Write-Warning "Then re-run this script."
exit 1
}
Write-Host "[Prepare] Testing WinRM connectivity to $VMIPAddress..."
try {
Test-WSMan -ComputerName $VMIPAddress -Credential $credential `
-Authentication Basic -ErrorAction Stop | Out-Null
Write-Host "[Prepare] WinRM reachable."
}
catch {
Write-Error @"
[Prepare] Cannot reach $VMIPAddress via WinRM.
Ensure:
- The VM is running and on VMnet8 (NAT) with internet access
- WinRM is enabled inside the VM (run as Administrator in VM):
winrm quickconfig -q
Enable-PSRemoting -Force -SkipNetworkProfileCheck
Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force
- Windows Firewall allows port 5985 inbound
- The IP is correct (check ipconfig inside the VM)
Error: $_
"@
exit 1
}
# ── Open session ──────────────────────────────────────────────────────────────
Write-Host "[Prepare] Opening PSSession..."
$session = New-PSSession `
-ComputerName $VMIPAddress `
-Credential $credential `
-SessionOption $sessionOptions `
-Authentication Basic `
-ErrorAction Stop
try {
# ── Copy Setup-TemplateVM.ps1 into the VM ─────────────────────────────────
$setupScript = Join-Path $scriptDir 'Setup-TemplateVM.ps1'
$guestScriptPath = 'C:\CI\Setup-TemplateVM.ps1'
Write-Host "[Prepare] Ensuring C:\CI exists on guest..."
Invoke-Command -Session $session -ScriptBlock {
if (-not (Test-Path 'C:\CI')) {
New-Item -ItemType Directory -Path 'C:\CI' -Force | Out-Null
}
}
Write-Host "[Prepare] Copying Setup-TemplateVM.ps1 -> guest $guestScriptPath ..."
Copy-Item -Path $setupScript -Destination $guestScriptPath -ToSession $session -Force
# ── Build argument list for Setup-TemplateVM.ps1 ─────────────────────────
$setupArgs = @{
BuildPassword = $BuildPassword
DotNetSdkVersion = $DotNetSdkVersion
}
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
# ── Run Setup-TemplateVM.ps1 inside the VM ────────────────────────────────
Write-Host "[Prepare] Running Setup-TemplateVM.ps1 inside VM (this will take a while)..."
Write-Host "[Prepare] Output from guest:"
Write-Host "------------------------------------------------------------"
$result = Invoke-Command -Session $session -ScriptBlock {
param($scriptPath, $scriptArgs)
Set-ExecutionPolicy Bypass -Scope Process -Force
$exitCode = 0
try {
& $scriptPath @scriptArgs
$exitCode = $LASTEXITCODE
if ($null -eq $exitCode) { $exitCode = 0 }
}
catch {
Write-Error $_
$exitCode = 1
}
return $exitCode
} -ArgumentList $guestScriptPath, $setupArgs
Write-Host "------------------------------------------------------------"
# ── Handle reboot required (exit 3010) ───────────────────────────────────
if ($result -eq 3010) {
Write-Host ""
Write-Warning "*** REBOOT REQUIRED after Windows Update. ***"
Write-Warning " The VM will reboot. Wait for it to come back up, then run:"
Write-Warning " .\Prepare-TemplateSetup.ps1 -VMIPAddress $VMIPAddress -SkipWindowsUpdate"
# Reboot the VM
Write-Host "[Prepare] Sending reboot command to VM..."
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force }
exit 3010
}
if ($result -ne 0) {
throw "Setup-TemplateVM.ps1 exited with code $result"
}
# ── Success ───────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " NOW DO THESE STEPS (manual):"
Write-Host ""
Write-Host " 1. Shut down the VM: Start -> Shut down"
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
Write-Host ""
Write-Host " 2. Take snapshot:"
Write-Host " VM -> Snapshot -> Take Snapshot"
Write-Host " Name it EXACTLY: BaseClean"
Write-Host ""
Write-Host " 3. Store CI credentials on this HOST:"
Write-Host " (run in elevated PowerShell with CredentialManager module)"
Write-Host " New-StoredCredential -Target 'BuildVMGuest' ``"
Write-Host " -UserName 'ci_build' -Password '$BuildPassword' ``"
Write-Host " -Persist LocalMachine"
Write-Host ""
Write-Host " 4. Verify runner online in Gitea: http://10.10.20.11:3100"
Write-Host " Admin -> Runners -> local-windows-runner -> should show Online"
Write-Host ""
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
}
+668
View File
@@ -0,0 +1,668 @@
#Requires -Version 5.1
<#
.SYNOPSIS
HOST-side script: delivers and runs Setup-WinBuild2022.ps1 inside the template VM.
.DESCRIPTION
Automates the template VM provisioning from the host machine:
1. Pre-flight validation: script presence, IP octet range, TCP/5986 reachable
2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) restored on exit
3. Validates host WinRM TrustedHosts applied correctly
4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck fails fast with actionable message
(PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe)
5. PSSession open step 4 and 5 are now merged
6. Ensures C:\CI exists on guest; validates creation
7. Copies Setup-WinBuild2022.ps1 into the VM; validates file present + size match
8. Runs Setup-WinBuild2022.ps1 inside the VM with the given parameters
(Setup performs per-step validation internally aborts on any failure)
9. Handles the reboot-required case (exit 3010) and optionally re-runs
10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command
(WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs)
11. Restores host TrustedHosts in finally block
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
The snapshot should only be taken after this script exits 0.
PREREQUISITES (do these manually before running this script):
a. VM NIC is set to VMnet8 (NAT) the VM needs internet for downloads
b. Windows Server is installed and booted
c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run:
winrm quickconfig -q
Enable-PSRemoting -Force -SkipNetworkProfileCheck
d. You know the IP address the VM got from DHCP on VMnet8 (NAT)
(check via: ipconfig inside the VM, or VMware VM Settings
Network Adapter Advanced MAC address, then check DHCP leases)
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
1. Shut down the VM
2. Take snapshot: VM Snapshot Take Snapshot name: BaseClean
(VM stays on VMnet8 NAT internet access is required for builds)
3. Store credentials on host (use the same password chosen during provisioning):
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password '<your-build-password>' -Persist LocalMachine
.PARAMETER VMXPath
Full path to the template VM's .vmx file (e.g. F:\CI\Templates\WinBuild\WinBuild.vmx).
When provided:
- If the VM is powered off, the script starts it automatically via vmrun.
- The guest IP is detected automatically via vmrun getGuestIPAddress (requires
VMware Tools installed in the guest).
- At the end, the snapshot is created automatically after provisioning.
Either -VMXPath or -VMIPAddress (or both) must be supplied.
.PARAMETER VMIPAddress
IP address of the template VM on VMnet8 NAT.
Optional when -VMXPath is provided (IP is auto-detected via VMware Tools).
Required when -VMXPath is omitted.
Example: 192.168.79.130
.PARAMETER AdminUsername
Administrator username inside the VM. Default: Administrator
.PARAMETER AdminPassword
Administrator password inside the VM. If not supplied, prompts interactively.
.PARAMETER BuildPassword
Password to set for the ci_build user inside the VM.
Must not be empty script prompts interactively if not supplied.
.PARAMETER BuildUsername
Local username for the CI build account inside the VM. Default: ci_build.
Passed through to Setup-WinBuild2022.ps1 and used in post-setup validation.
.PARAMETER DotNetSdkVersion
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
.PARAMETER SkipWindowsUpdate
Pass through to Setup-WinBuild2022.ps1 to skip the Windows Update step.
.PARAMETER SnapshotName
Name of the snapshot to create. Default: BaseClean (case-sensitive used by New-BuildVM.ps1).
Only relevant when -TakeSnapshot is set.
.PARAMETER StoreCredential
After provisioning completes, store BuildUsername/BuildPassword in Windows
Credential Manager (target: CredentialTarget). Installs the CredentialManager
module (CurrentUser scope) automatically if not present.
Equivalent to running manually:
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password '<pwd>' -Persist LocalMachine
.PARAMETER CredentialTarget
Windows Credential Manager target name used when -StoreCredential is set.
Default: BuildVMGuest (matches the target expected by Invoke-CIJob.ps1 and
the other CI scripts that load guest credentials via Get-StoredCredential).
.EXAMPLE
# Fully automated — VMX path only (power-on + IP detection + snapshot automatic):
.\Prepare-WinBuild2022.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
# Skip Windows Update (already applied):
.\Prepare-WinBuild2022.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
-SkipWindowsUpdate -StoreCredential
# Explicit IP (manual start, no VMware Tools required for IP detection):
.\Prepare-WinBuild2022.ps1 -VMIPAddress 192.168.79.130 `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
# Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress:
.\Prepare-WinBuild2022.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' -VMIPAddress 192.168.79.130 `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
#>
[CmdletBinding()]
param(
# Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot.
# Either -VMXPath or -VMIPAddress (or both) must be supplied.
[string] $VMXPath = '',
# Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via VMware Tools).
[string] $VMIPAddress = '',
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword = '',
[string] $BuildPassword = '',
[string] $BuildUsername = 'ci_build',
[string] $DotNetSdkVersion = '10.0',
[switch] $SkipWindowsUpdate,
# Skip disk cleanup inside the VM (cleanmgr + DISM + cache clear) — speeds up re-runs
[switch] $SkipCleanup,
# Snapshot name (case-sensitive — must match GITEA_CI_SNAPSHOT_NAME in runner/config.yaml)
[string] $SnapshotName = 'BaseClean',
# Store BuildUsername/BuildPassword in Windows Credential Manager after provisioning
[switch] $StoreCredential,
# Credential Manager target name (must match what CI scripts use via Get-StoredCredential)
[string] $CredentialTarget = 'BuildVMGuest'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Locate vmrun.exe ──────────────────────────────────────────────────────────
$vmrunCandidates = @(
'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
'C:\Program Files\VMware\VMware Workstation\vmrun.exe'
)
$vmrunExe = $vmrunCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $vmrunExe) {
$vmrunFound = Get-Command vmrun -ErrorAction SilentlyContinue
if ($vmrunFound) { $vmrunExe = $vmrunFound.Source }
}
if (-not $vmrunExe) { throw "[Prepare] vmrun.exe not found. Install VMware Workstation or add it to PATH." }
# ── Validate input: need VMXPath or VMIPAddress ───────────────────────────────
if ($VMXPath -eq '' -and $VMIPAddress -eq '') {
throw "[Prepare] Provide -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)."
}
# ── Power on VM and detect IP via VMXPath ────────────────────────────────────
$vmWasPoweredOn = $false
if ($VMXPath -ne '') {
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" }
# Check if already running
$runningList = @(& $vmrunExe list 2>&1 |
Where-Object { $_ -match '\.vmx$' } | ForEach-Object { $_.Trim() })
if ($runningList -notcontains $VMXPath) {
Write-Host "[Prepare] VM not running — starting: $VMXPath"
& $vmrunExe start $VMXPath
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start exited $LASTEXITCODE." }
$vmWasPoweredOn = $true
Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..."
}
else {
Write-Host "[Prepare] VM already running: $VMXPath"
}
# Auto-detect IP if not supplied
if ($VMIPAddress -eq '') {
Write-Host "[Prepare] Detecting guest IP via VMware Tools (getGuestIPAddress -wait)..."
$detectedIP = (& $vmrunExe getGuestIPAddress $VMXPath -wait 2>&1).Trim()
if ($detectedIP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
throw "[Prepare] Could not get guest IP. Got: '$detectedIP'. Ensure VMware Tools is installed."
}
$VMIPAddress = $detectedIP
Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green
}
}
function Assert-Step {
param(
[Parameter(Mandatory)] [string] $StepName,
[Parameter(Mandatory)] [string] $Description,
[Parameter(Mandatory)] [scriptblock] $Test
)
$ok = $false
try { $ok = [bool](& $Test) }
catch { throw "[Prepare/$StepName] Validation threw on '$Description': $_" }
if (-not $ok) {
throw "[Prepare/$StepName] Validation failed: $Description"
}
Write-Host " [OK] $Description" -ForegroundColor Green
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Pre-flight validation
Assert-Step 'PreFlight' 'script directory resolved' { Test-Path $scriptDir -PathType Container }
Assert-Step 'PreFlight' 'Setup-WinBuild2022.ps1 present alongside this script' {
Test-Path (Join-Path $scriptDir 'Setup-WinBuild2022.ps1') -PathType Leaf
}
Assert-Step 'PreFlight' "VMIPAddress octets in 0..255" {
$parts = $VMIPAddress.Split('.') | ForEach-Object { [int]$_ }
($parts.Count -eq 4) -and -not ($parts | Where-Object { $_ -lt 0 -or $_ -gt 255 })
}
# WinRM readiness — retry loop (longer timeout if VM was just powered on by this script)
$winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 }
Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5986 (timeout ${winrmTimeoutSec}s)..."
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
$winrmReady = $false
while ([datetime]::UtcNow -lt $winrmDeadline) {
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 `
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
$winrmReady = $true; break
}
Start-Sleep -Seconds 5
Write-Host " ... waiting for WinRM..."
}
Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady }
# Prompt for BuildPassword if not supplied — never use a hardcoded default.
if ($BuildPassword -eq '') {
Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:"
$secPwd = Read-Host -Prompt " BuildPassword" -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
$BuildPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
# ── Build PSCredential ────────────────────────────────────────────────────────
if ($AdminPassword -eq '') {
Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):"
$credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)"
}
else {
$secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd)
}
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
# ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ──────
# HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts
# needs appending so PS accepts the non-domain VM. Restored in the finally block.
$prevTrustedHosts = $null
try {
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
# Add VM IP to TrustedHosts if not already present
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
}
Write-Host "[Prepare] Host TrustedHosts configured."
Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" {
$th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
$th -eq '*' -or $th -like "*$VMIPAddress*"
}
}
catch {
Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)."
Write-Warning "Run once in an elevated PowerShell on the HOST:"
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
Write-Warning "Then re-run this script."
exit 1
}
# ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ──
# New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert.
Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..."
$session = try {
New-PSSession `
-ComputerName $VMIPAddress `
-Credential $credential `
-SessionOption $sessionOptions `
-UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
} catch {
Write-Error @"
[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
Ensure:
- The VM is running and on VMnet8 (NAT) with internet access
- WinRM HTTPS is enabled inside the VM Deploy-WinBuild2022.ps1 post-install.ps1
must have run successfully (creates self-signed cert + HTTPS listener on 5986)
- Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest)
- The IP is correct (check ipconfig inside the VM)
Error: $_
"@
exit 1
}
try {
# ── Copy Setup-WinBuild2022.ps1 into the VM ───────────────────────────────
$setupScript = Join-Path $scriptDir 'Setup-WinBuild2022.ps1'
$guestScriptPath = 'C:\CI\Setup-WinBuild2022.ps1'
Write-Host "[Prepare] Ensuring C:\CI exists on guest..."
Invoke-Command -Session $session -ScriptBlock {
if (-not (Test-Path 'C:\CI')) {
New-Item -ItemType Directory -Path 'C:\CI' -Force | Out-Null
}
}
Assert-Step 'GuestPrep' 'C:\CI exists on guest' {
Invoke-Command -Session $session -ScriptBlock { Test-Path 'C:\CI' -PathType Container }
}
Write-Host "[Prepare] Copying Setup-WinBuild2022.ps1 -> guest $guestScriptPath (UTF-8 BOM)..."
# Do NOT use Copy-Item -ToSession: it binary-copies the file.
# If the source file has no UTF-8 BOM, PowerShell 5.1 on the guest reads it as
# Windows-1252, misinterpreting multi-byte UTF-8 sequences (e.g. em dash U+2014
# encodes as E2 80 94; byte 0x94 in Win-1252 = curly right-quote, which PS 5.1
# treats as a string terminator → parse errors throughout the script).
#
# Fix: read content on host as Unicode string, transmit via WinRM (XML/Unicode),
# write on guest with explicit UTF-8 BOM via System.Text.UTF8Encoding($true).
# PS 5.1 detects the BOM and reads the file as UTF-8 correctly.
$scriptContent = [System.IO.File]::ReadAllText($setupScript, [System.Text.Encoding]::UTF8)
Invoke-Command -Session $session -ScriptBlock {
param($content, $path)
$utf8Bom = New-Object System.Text.UTF8Encoding($true) # $true = emit BOM
[System.IO.File]::WriteAllText($path, $content, $utf8Bom)
} -ArgumentList $scriptContent, $guestScriptPath
# Validation — file present and large enough (BOM transfer changes byte count vs host)
$hostSize = (Get-Item $setupScript).Length
Assert-Step 'GuestPrep' "guest script present at $guestScriptPath" {
Invoke-Command -Session $session -ScriptBlock { param($p) Test-Path $p -PathType Leaf } -ArgumentList $guestScriptPath
}
Assert-Step 'GuestPrep' "guest script size reasonable (host=$hostSize bytes ±6 for BOM)" {
$remoteSize = Invoke-Command -Session $session -ScriptBlock {
param($p) (Get-Item $p -ErrorAction SilentlyContinue).Length
} -ArgumentList $guestScriptPath
# UTF-8 BOM adds 3 bytes; allow small variance
[int64]$remoteSize -ge ([int64]$hostSize - 6) -and [int64]$remoteSize -le ([int64]$hostSize + 6)
}
# ── Build argument list for Setup-WinBuild2022.ps1 ───────────────────────
$setupArgs = @{
BuildPassword = $BuildPassword
BuildUsername = $BuildUsername
DotNetSdkVersion = $DotNetSdkVersion
AdminPassword = $AdminPassword
}
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
# ── Run Setup-WinBuild2022.ps1 inside the VM ──────────────────────────────
# Setup may return exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) when Windows
# Update applied patches that need a reboot. We loop: reboot guest, wait for
# WinRM, reopen session, re-invoke Setup. Steps 1-5 are idempotent (~30 s);
# Step 6 picks up where the previous run left off (no leftover updates →
# exit 0). Hard cap on iterations to avoid an infinite loop.
$maxWuIterations = 10
$rebootDeadlineMin = 20
function Invoke-GuestSetup {
param([Parameter(Mandatory)] $Session,
[Parameter(Mandatory)] [string] $ScriptPath,
[Parameter(Mandatory)] [hashtable] $ScriptArgs)
Invoke-Command -Session $Session -ScriptBlock {
param($scriptPath, $scriptArgs)
Set-ExecutionPolicy Bypass -Scope Process -Force
$exitCode = 0
try {
& $scriptPath @scriptArgs
$exitCode = $LASTEXITCODE
if ($null -eq $exitCode) { $exitCode = 0 }
} catch {
Write-Error $_
$exitCode = 1
}
return $exitCode
} -ArgumentList $ScriptPath, $ScriptArgs
}
function Wait-GuestWinRMReady {
param([Parameter(Mandatory)] [string] $IP,
[Parameter(Mandatory)] $Cred,
[Parameter(Mandatory)] $SessionOptions,
[int] $TimeoutMin = 20)
$deadline = (Get-Date).AddMinutes($TimeoutMin)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 10
try {
# Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe.
$probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL `
-SessionOption $SessionOptions -Credential $Cred `
-Authentication Basic -ErrorAction Stop
Remove-PSSession $probe -ErrorAction SilentlyContinue
return $true
} catch { }
}
return $false
}
Write-Host "[Prepare] Running Setup-WinBuild2022.ps1 inside VM (this will take a while)..."
for ($iter = 1; $iter -le $maxWuIterations; $iter++) {
Write-Host "[Prepare] Setup iteration $iter/$maxWuIterations"
Write-Host "[Prepare] Output from guest:"
Write-Host "------------------------------------------------------------"
$result = $null
try {
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
# VM rebooted mid-update (WU triggered OS restart before Setup could return 3010).
# Treat as reboot-required: close dead session, fall through to the reboot-wait block.
Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..."
Remove-PSSession $session -ErrorAction SilentlyContinue
$session = $null
$result = 3010
}
Write-Host "------------------------------------------------------------"
Write-Host "[Prepare] Iteration $iter exit code: $result"
if ($result -eq 0) { break }
if ($result -ne 3010) {
throw "Setup-WinBuild2022.ps1 exited with code $result"
}
# exit 3010 (or transport error treated as 3010) → WU needs reboot.
# Reboot guest if session still alive, wait for WinRM, reopen session, loop.
if ($iter -eq $maxWuIterations) {
throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)"
}
Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..."
if ($session) {
try {
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue
} catch { }
Remove-PSSession $session -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 30 # let WinRM tear down before polling
Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..."
if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential `
-SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) {
throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot"
}
Write-Host "[Prepare] Guest WinRM ready, reopening session..."
$session = New-PSSession -ComputerName $VMIPAddress -Credential $credential `
-SessionOption $sessionOptions -UseSSL -Port 5986 `
-Authentication Basic -ErrorAction Stop
}
# ── Post-setup remote validation ──────────────────────────────────────────
Write-Host "[Prepare] Running post-setup validation against guest..."
$guestState = Invoke-Command -Session $session -ScriptBlock {
param($BuildUser)
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH','User')
[PSCustomObject]@{
WinRMRunning = (Get-Service WinRM -ErrorAction SilentlyContinue).Status -eq 'Running'
UserExists = [bool](Get-LocalUser -Name $BuildUser -ErrorAction SilentlyContinue)
UserIsAdmin = [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUser)
UACDisabled = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA -eq 0
FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true)
DotNetVersion = (Get-Command dotnet -ErrorAction SilentlyContinue) | ForEach-Object { (& $_.Source --version 2>&1) }
PythonExists = Test-Path 'C:\Python\python.exe' -PathType Leaf
MSBuildExists = Test-Path $msbuildPath -PathType Leaf
CIDirsPresent = (Test-Path 'C:\CI\build') -and (Test-Path 'C:\CI\output') -and (Test-Path 'C:\CI\scripts')
}
} -ArgumentList $BuildUsername
Assert-Step 'PostSetup' 'guest WinRM Running' { $guestState.WinRMRunning }
Assert-Step 'PostSetup' "guest user '$BuildUsername' exists" { $guestState.UserExists }
Assert-Step 'PostSetup' "guest user '$BuildUsername' is admin" { $guestState.UserIsAdmin }
Assert-Step 'PostSetup' 'guest UAC disabled' { $guestState.UACDisabled }
Assert-Step 'PostSetup' 'guest firewall disabled' { $guestState.FirewallOff }
Assert-Step 'PostSetup' 'guest .NET SDK installed' { [bool]$guestState.DotNetVersion }
Assert-Step 'PostSetup' 'guest Python installed' { $guestState.PythonExists }
Assert-Step 'PostSetup' 'guest MSBuild present' { $guestState.MSBuildExists }
Assert-Step 'PostSetup' 'guest C:\CI\{build,output,scripts} present' { $guestState.CIDirsPresent }
Write-Host "[Prepare] All post-setup validations passed (.NET $($guestState.DotNetVersion))." -ForegroundColor Green
# ── Store credentials in Windows Credential Manager (optional) ────────────
if ($StoreCredential) {
Write-Host "[Prepare] Storing CI credentials in Windows Credential Manager (target: '$CredentialTarget')..."
if (-not (Get-Module -ListAvailable -Name CredentialManager)) {
Write-Host "[Prepare] CredentialManager module not found — installing (CurrentUser scope)..."
Install-Module -Name CredentialManager -Scope CurrentUser -Force -ErrorAction Stop
}
Import-Module CredentialManager -ErrorAction Stop
New-StoredCredential `
-Target $CredentialTarget `
-UserName $BuildUsername `
-Password $BuildPassword `
-Persist LocalMachine `
-ErrorAction Stop | Out-Null
Write-Host "[Prepare] Credentials stored: target='$CredentialTarget' user='$BuildUsername'." -ForegroundColor Green
}
Write-Host "[Prepare] All validations passed." -ForegroundColor Green
# ── Shutdown + snapshot prompt ─────────────────────────────────────────────
# vmrun.exe already located at script start ($vmrunExe)
# Auto-discover VMX path via 'vmrun list' while VM is still running (if not known)
if ($VMXPath -eq '') {
Write-Host "[Prepare] Discovering VMX path from running VMs..."
$runningVMs = @(& $vmrunExe list 2>&1 |
Where-Object { $_ -match '\.vmx$' } |
ForEach-Object { $_.Trim() })
if ($runningVMs.Count -eq 0) {
throw "[Prepare] No running VMs found via 'vmrun list'. Is the template VM still up?"
}
elseif ($runningVMs.Count -eq 1) {
$VMXPath = $runningVMs[0]
Write-Host "[Prepare] VMX auto-detected: $VMXPath" -ForegroundColor Green
}
else {
# Multiple VMs — prefer one under \CI\Templates\
$ciVMs = @($runningVMs | Where-Object { $_ -like '*\CI\Templates\*' })
if ($ciVMs.Count -eq 1) {
$VMXPath = $ciVMs[0]
Write-Host "[Prepare] VMX auto-detected (CI template): $VMXPath" -ForegroundColor Green
}
else {
Write-Host "[Prepare] Multiple VMs running — select one:"
for ($i = 0; $i -lt $runningVMs.Count; $i++) {
Write-Host " [$($i+1)] $($runningVMs[$i])"
}
$idx = [int](Read-Host "[Prepare] Enter number") - 1
if ($idx -lt 0 -or $idx -ge $runningVMs.Count) { throw "[Prepare] Invalid selection." }
$VMXPath = $runningVMs[$idx]
}
}
}
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found on disk: '$VMXPath'" }
# Prompt
Write-Host "------------------------------------------------------------"
Write-Host ""
$choice = Read-Host "[Prepare] Shut down VM and create snapshot '$SnapshotName'? [Y/N]"
if ($choice -match '^[Yy]') {
# Send graceful shutdown
Write-Host "[Prepare] Sending graceful shutdown to VM..."
try {
Invoke-Command -Session $session -ScriptBlock { Stop-Computer -Force } -ErrorAction Stop
Write-Host "[Prepare] Shutdown command sent." -ForegroundColor Green
}
catch {
Write-Warning "[Prepare] Shutdown command failed (VM may have already shut down): $_"
}
# Close session — VM is shutting down, no further WinRM needed
Remove-PSSession $session -ErrorAction SilentlyContinue
# Wait for VM to power off (poll vmrun list)
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
$shutdownDeadline = [datetime]::UtcNow.AddSeconds(120)
$poweredOff = $false
while ([datetime]::UtcNow -lt $shutdownDeadline) {
$runningList = & $vmrunExe list 2>&1 | Out-String
if ($runningList -notmatch [regex]::Escape($VMXPath)) { $poweredOff = $true; break }
Start-Sleep -Seconds 5
Write-Host " ... waiting for shutdown..."
}
if (-not $poweredOff) { throw "[Prepare] VM did not power off within 120 s. Check VMware Workstation." }
# Check for existing snapshot with same name
$existingSnaps = & $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String
$doCreate = $true
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
Write-Warning "[Prepare] Snapshot '$SnapshotName' already exists."
$delChoice = Read-Host "[Prepare] Delete existing snapshot and recreate? [Y/N]"
if ($delChoice -match '^[Yy]') {
Write-Host "[Prepare] Deleting existing snapshot '$SnapshotName'..."
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun deleteSnapshot exited $LASTEXITCODE." }
Write-Host "[Prepare] Existing snapshot deleted." -ForegroundColor Green
}
else {
Write-Host "[Prepare] Keeping existing snapshot — skipping creation." -ForegroundColor Yellow
$doCreate = $false
}
}
# Create snapshot
if ($doCreate) {
Write-Host "[Prepare] Creating snapshot '$SnapshotName'..." -ForegroundColor Green
& $vmrunExe -T ws snapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun snapshot exited $LASTEXITCODE." }
Write-Host "[Prepare] Snapshot '$SnapshotName' created successfully." -ForegroundColor Green
}
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
if ($StoreCredential) {
Write-Host " [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
}
Write-Host ""
}
else {
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " MANUAL STEPS REQUIRED:"
Write-Host ""
Write-Host " 1. Shut down the VM: Start -> Shut down"
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
Write-Host ""
Write-Host " 2. Take snapshot in VMware Workstation:"
Write-Host " VM -> Snapshot -> Take Snapshot"
Write-Host " Name it EXACTLY: $SnapshotName"
Write-Host ""
if ($StoreCredential) {
Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
}
else {
Write-Host " 3. Store CI credentials on this HOST (run in elevated PowerShell):"
Write-Host " .\Prepare-WinBuild2022.ps1 -VMIPAddress $VMIPAddress -StoreCredential"
Write-Host " (or manually:)"
Write-Host " New-StoredCredential -Target '$CredentialTarget' ``"
Write-Host " -UserName '$BuildUsername' -Password '<your-build-password>' ``"
Write-Host " -Persist LocalMachine"
}
Write-Host ""
}
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
# Restore host TrustedHosts to its original value
if ($null -ne $prevTrustedHosts) {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
}
Write-Host "[Prepare] Host TrustedHosts restored."
}
+668
View File
@@ -0,0 +1,668 @@
#Requires -Version 5.1
<#
.SYNOPSIS
HOST-side script: delivers and runs Setup-WinBuild2025.ps1 inside the template VM.
.DESCRIPTION
Automates the template VM provisioning from the host machine:
1. Pre-flight validation: script presence, IP octet range, TCP/5986 reachable
2. Configures host WinRM client (TrustedHosts only, HTTPS needs no AllowUnencrypted) restored on exit
3. Validates host WinRM TrustedHosts applied correctly
4. Opens PSSession -UseSSL -Port 5986 -SkipCACheck fails fast with actionable message
(PS 5.1 Test-WSMan has no -SessionOption, so PSSession is used as the connectivity probe)
5. PSSession open step 4 and 5 are now merged
6. Ensures C:\CI exists on guest; validates creation
7. Copies Setup-WinBuild2025.ps1 into the VM; validates file present + size match
8. Runs Setup-WinBuild2025.ps1 inside the VM with the given parameters
(Setup performs per-step validation internally aborts on any failure)
9. Handles the reboot-required case (exit 3010) and optionally re-runs
10. Post-setup remote validation: 9 guest-state checks via a single Invoke-Command
(WinRM, user+admin, UAC, firewall, .NET, Python, MSBuild, CI dirs)
11. Restores host TrustedHosts in finally block
Every validation step uses Assert-Step: prints [OK] on success, throws on failure.
The snapshot should only be taken after this script exits 0.
PREREQUISITES (do these manually before running this script):
a. VM NIC is set to VMnet8 (NAT) the VM needs internet for downloads
b. Windows Server is installed and booted
c. WinRM is enabled inside the VM. From an elevated cmd/PS in the VM run:
winrm quickconfig -q
Enable-PSRemoting -Force -SkipNetworkProfileCheck
d. You know the IP address the VM got from DHCP on VMnet8 (NAT)
(check via: ipconfig inside the VM, or VMware VM Settings
Network Adapter Advanced MAC address, then check DHCP leases)
AFTER THIS SCRIPT COMPLETES (exit 0, all validations passed):
1. Shut down the VM
2. Take snapshot: VM Snapshot Take Snapshot name: BaseClean
(VM stays on VMnet8 NAT internet access is required for builds)
3. Store credentials on host (use the same password chosen during provisioning):
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password '<your-build-password>' -Persist LocalMachine
.PARAMETER VMXPath
Full path to the template VM's .vmx file (e.g. F:\CI\Templates\WinBuild\WinBuild.vmx).
When provided:
- If the VM is powered off, the script starts it automatically via vmrun.
- The guest IP is detected automatically via vmrun getGuestIPAddress (requires
VMware Tools installed in the guest).
- At the end, the snapshot is created automatically after provisioning.
Either -VMXPath or -VMIPAddress (or both) must be supplied.
.PARAMETER VMIPAddress
IP address of the template VM on VMnet8 NAT.
Optional when -VMXPath is provided (IP is auto-detected via VMware Tools).
Required when -VMXPath is omitted.
Example: 192.168.79.130
.PARAMETER AdminUsername
Administrator username inside the VM. Default: Administrator
.PARAMETER AdminPassword
Administrator password inside the VM. If not supplied, prompts interactively.
.PARAMETER BuildPassword
Password to set for the ci_build user inside the VM.
Must not be empty script prompts interactively if not supplied.
.PARAMETER BuildUsername
Local username for the CI build account inside the VM. Default: ci_build.
Passed through to Setup-WinBuild2025.ps1 and used in post-setup validation.
.PARAMETER DotNetSdkVersion
.NET SDK channel to install in the VM. Default: 10.0 (matches host SDK).
.PARAMETER SkipWindowsUpdate
Pass through to Setup-WinBuild2025.ps1 to skip the Windows Update step.
.PARAMETER SnapshotName
Name of the snapshot to create. Default: BaseClean (case-sensitive used by New-BuildVM.ps1).
Only relevant when -TakeSnapshot is set.
.PARAMETER StoreCredential
After provisioning completes, store BuildUsername/BuildPassword in Windows
Credential Manager (target: CredentialTarget). Installs the CredentialManager
module (CurrentUser scope) automatically if not present.
Equivalent to running manually:
New-StoredCredential -Target 'BuildVMGuest' -UserName 'ci_build' `
-Password '<pwd>' -Persist LocalMachine
.PARAMETER CredentialTarget
Windows Credential Manager target name used when -StoreCredential is set.
Default: BuildVMGuest (matches the target expected by Invoke-CIJob.ps1 and
the other CI scripts that load guest credentials via Get-StoredCredential).
.EXAMPLE
# Fully automated — VMX path only (power-on + IP detection + snapshot automatic):
.\Prepare-WinBuild2025.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
# Skip Windows Update (already applied):
.\Prepare-WinBuild2025.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' `
-SkipWindowsUpdate -StoreCredential
# Explicit IP (manual start, no VMware Tools required for IP detection):
.\Prepare-WinBuild2025.ps1 -VMIPAddress 192.168.79.130 `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
# Both supplied — uses VMXPath for power-on/snapshot, explicit IP skips getGuestIPAddress:
.\Prepare-WinBuild2025.ps1 `
-VMXPath 'F:\CI\Templates\WinBuild\WinBuild.vmx' -VMIPAddress 192.168.79.130 `
-BuildPassword 'StrongCIPass!2026' -StoreCredential
#>
[CmdletBinding()]
param(
# Path to the VM .vmx — enables auto power-on + IP detection + auto snapshot.
# Either -VMXPath or -VMIPAddress (or both) must be supplied.
[string] $VMXPath = '',
# Guest IP on VMnet8 NAT. Optional when -VMXPath provided (auto-detected via VMware Tools).
[string] $VMIPAddress = '',
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword = '',
[string] $BuildPassword = '',
[string] $BuildUsername = 'ci_build',
[string] $DotNetSdkVersion = '10.0',
[switch] $SkipWindowsUpdate,
# Skip disk cleanup inside the VM (cleanmgr + DISM + cache clear) — speeds up re-runs
[switch] $SkipCleanup,
# Snapshot name (case-sensitive — must match GITEA_CI_SNAPSHOT_NAME in runner/config.yaml)
[string] $SnapshotName = 'BaseClean',
# Store BuildUsername/BuildPassword in Windows Credential Manager after provisioning
[switch] $StoreCredential,
# Credential Manager target name (must match what CI scripts use via Get-StoredCredential)
[string] $CredentialTarget = 'BuildVMGuest'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Locate vmrun.exe ──────────────────────────────────────────────────────────
$vmrunCandidates = @(
'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
'C:\Program Files\VMware\VMware Workstation\vmrun.exe'
)
$vmrunExe = $vmrunCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $vmrunExe) {
$vmrunFound = Get-Command vmrun -ErrorAction SilentlyContinue
if ($vmrunFound) { $vmrunExe = $vmrunFound.Source }
}
if (-not $vmrunExe) { throw "[Prepare] vmrun.exe not found. Install VMware Workstation or add it to PATH." }
# ── Validate input: need VMXPath or VMIPAddress ───────────────────────────────
if ($VMXPath -eq '' -and $VMIPAddress -eq '') {
throw "[Prepare] Provide -VMXPath (auto power-on + IP detection) or -VMIPAddress (or both)."
}
# ── Power on VM and detect IP via VMXPath ────────────────────────────────────
$vmWasPoweredOn = $false
if ($VMXPath -ne '') {
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found: '$VMXPath'" }
# Check if already running
$runningList = @(& $vmrunExe list 2>&1 |
Where-Object { $_ -match '\.vmx$' } | ForEach-Object { $_.Trim() })
if ($runningList -notcontains $VMXPath) {
Write-Host "[Prepare] VM not running — starting: $VMXPath"
& $vmrunExe start $VMXPath
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun start exited $LASTEXITCODE." }
$vmWasPoweredOn = $true
Write-Host "[Prepare] VM started. Waiting for guest OS to initialize..."
}
else {
Write-Host "[Prepare] VM already running: $VMXPath"
}
# Auto-detect IP if not supplied
if ($VMIPAddress -eq '') {
Write-Host "[Prepare] Detecting guest IP via VMware Tools (getGuestIPAddress -wait)..."
$detectedIP = (& $vmrunExe getGuestIPAddress $VMXPath -wait 2>&1).Trim()
if ($detectedIP -notmatch '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$') {
throw "[Prepare] Could not get guest IP. Got: '$detectedIP'. Ensure VMware Tools is installed."
}
$VMIPAddress = $detectedIP
Write-Host "[Prepare] Guest IP: $VMIPAddress" -ForegroundColor Green
}
}
function Assert-Step {
param(
[Parameter(Mandatory)] [string] $StepName,
[Parameter(Mandatory)] [string] $Description,
[Parameter(Mandatory)] [scriptblock] $Test
)
$ok = $false
try { $ok = [bool](& $Test) }
catch { throw "[Prepare/$StepName] Validation threw on '$Description': $_" }
if (-not $ok) {
throw "[Prepare/$StepName] Validation failed: $Description"
}
Write-Host " [OK] $Description" -ForegroundColor Green
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# Pre-flight validation
Assert-Step 'PreFlight' 'script directory resolved' { Test-Path $scriptDir -PathType Container }
Assert-Step 'PreFlight' 'Setup-WinBuild2025.ps1 present alongside this script' {
Test-Path (Join-Path $scriptDir 'Setup-WinBuild2025.ps1') -PathType Leaf
}
Assert-Step 'PreFlight' "VMIPAddress octets in 0..255" {
$parts = $VMIPAddress.Split('.') | ForEach-Object { [int]$_ }
($parts.Count -eq 4) -and -not ($parts | Where-Object { $_ -lt 0 -or $_ -gt 255 })
}
# WinRM readiness — retry loop (longer timeout if VM was just powered on by this script)
$winrmTimeoutSec = if ($vmWasPoweredOn) { 180 } else { 20 }
Write-Host "[Prepare] Waiting for WinRM on $VMIPAddress`:5986 (timeout ${winrmTimeoutSec}s)..."
$winrmDeadline = [datetime]::UtcNow.AddSeconds($winrmTimeoutSec)
$winrmReady = $false
while ([datetime]::UtcNow -lt $winrmDeadline) {
if (Test-NetConnection -ComputerName $VMIPAddress -Port 5986 `
-InformationLevel Quiet -WarningAction SilentlyContinue -ErrorAction SilentlyContinue) {
$winrmReady = $true; break
}
Start-Sleep -Seconds 5
Write-Host " ... waiting for WinRM..."
}
Assert-Step 'PreFlight' "VMIPAddress $VMIPAddress reachable on TCP/5986" { $winrmReady }
# Prompt for BuildPassword if not supplied — never use a hardcoded default.
if ($BuildPassword -eq '') {
Write-Host "[Prepare] No -BuildPassword supplied. Enter password for the CI build account inside the VM:"
$secPwd = Read-Host -Prompt " BuildPassword" -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)
$BuildPassword = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
}
# ── Build PSCredential ────────────────────────────────────────────────────────
if ($AdminPassword -eq '') {
Write-Host "[Prepare] Enter administrator credentials for the template VM ($VMIPAddress):"
$credential = Get-Credential -UserName $AdminUsername -Message "Template VM administrator ($VMIPAddress)"
}
else {
$secPwd = ConvertTo-SecureString $AdminPassword -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($AdminUsername, $secPwd)
}
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
# ── Configure HOST-side WinRM client (TrustedHosts for HTTPS Basic auth) ──────
# HTTPS/5986 does not require AllowUnencrypted on the host side. Only TrustedHosts
# needs appending so PS accepts the non-domain VM. Restored in the finally block.
$prevTrustedHosts = $null
try {
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts -ErrorAction Stop).Value
# Add VM IP to TrustedHosts if not already present
if ($prevTrustedHosts -ne '*' -and $prevTrustedHosts -notlike "*$VMIPAddress*") {
$newTrusted = if ($prevTrustedHosts -eq '') { $VMIPAddress } else { "$prevTrustedHosts,$VMIPAddress" }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newTrusted -Force -ErrorAction Stop
}
Write-Host "[Prepare] Host TrustedHosts configured."
Assert-Step 'HostWinRM' "TrustedHosts includes $VMIPAddress (or '*')" {
$th = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
$th -eq '*' -or $th -like "*$VMIPAddress*"
}
}
catch {
Write-Warning "[Prepare] Could not configure host WinRM TrustedHosts (need elevation?)."
Write-Warning "Run once in an elevated PowerShell on the HOST:"
Write-Warning " Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force"
Write-Warning "Then re-run this script."
exit 1
}
# ── Open session (Test-WSMan skipped — PS 5.1 Test-WSMan has no -SessionOption for HTTPS) ──
# New-PSSession with -SkipCACheck handles cert validation for self-signed lab cert.
Write-Host "[Prepare] Opening PSSession to $VMIPAddress`:5986 (HTTPS)..."
$session = try {
New-PSSession `
-ComputerName $VMIPAddress `
-Credential $credential `
-SessionOption $sessionOptions `
-UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
} catch {
Write-Error @"
[Prepare] Cannot open PSSession to $VMIPAddress via WinRM HTTPS (port 5986).
Ensure:
- The VM is running and on VMnet8 (NAT) with internet access
- WinRM HTTPS is enabled inside the VM Deploy-WinBuild2025.ps1 post-install.ps1
must have run successfully (creates self-signed cert + HTTPS listener on 5986)
- Windows Firewall allows port 5986 inbound (or firewall is disabled on the guest)
- The IP is correct (check ipconfig inside the VM)
Error: $_
"@
exit 1
}
try {
# ── Copy Setup-WinBuild2025.ps1 into the VM ───────────────────────────────
$setupScript = Join-Path $scriptDir 'Setup-WinBuild2025.ps1'
$guestScriptPath = 'C:\CI\Setup-WinBuild2025.ps1'
Write-Host "[Prepare] Ensuring C:\CI exists on guest..."
Invoke-Command -Session $session -ScriptBlock {
if (-not (Test-Path 'C:\CI')) {
New-Item -ItemType Directory -Path 'C:\CI' -Force | Out-Null
}
}
Assert-Step 'GuestPrep' 'C:\CI exists on guest' {
Invoke-Command -Session $session -ScriptBlock { Test-Path 'C:\CI' -PathType Container }
}
Write-Host "[Prepare] Copying Setup-WinBuild2025.ps1 -> guest $guestScriptPath (UTF-8 BOM)..."
# Do NOT use Copy-Item -ToSession: it binary-copies the file.
# If the source file has no UTF-8 BOM, PowerShell 5.1 on the guest reads it as
# Windows-1252, misinterpreting multi-byte UTF-8 sequences (e.g. em dash U+2014
# encodes as E2 80 94; byte 0x94 in Win-1252 = curly right-quote, which PS 5.1
# treats as a string terminator → parse errors throughout the script).
#
# Fix: read content on host as Unicode string, transmit via WinRM (XML/Unicode),
# write on guest with explicit UTF-8 BOM via System.Text.UTF8Encoding($true).
# PS 5.1 detects the BOM and reads the file as UTF-8 correctly.
$scriptContent = [System.IO.File]::ReadAllText($setupScript, [System.Text.Encoding]::UTF8)
Invoke-Command -Session $session -ScriptBlock {
param($content, $path)
$utf8Bom = New-Object System.Text.UTF8Encoding($true) # $true = emit BOM
[System.IO.File]::WriteAllText($path, $content, $utf8Bom)
} -ArgumentList $scriptContent, $guestScriptPath
# Validation — file present and large enough (BOM transfer changes byte count vs host)
$hostSize = (Get-Item $setupScript).Length
Assert-Step 'GuestPrep' "guest script present at $guestScriptPath" {
Invoke-Command -Session $session -ScriptBlock { param($p) Test-Path $p -PathType Leaf } -ArgumentList $guestScriptPath
}
Assert-Step 'GuestPrep' "guest script size reasonable (host=$hostSize bytes ±6 for BOM)" {
$remoteSize = Invoke-Command -Session $session -ScriptBlock {
param($p) (Get-Item $p -ErrorAction SilentlyContinue).Length
} -ArgumentList $guestScriptPath
# UTF-8 BOM adds 3 bytes; allow small variance
[int64]$remoteSize -ge ([int64]$hostSize - 6) -and [int64]$remoteSize -le ([int64]$hostSize + 6)
}
# ── Build argument list for Setup-WinBuild2025.ps1 ───────────────────────
$setupArgs = @{
BuildPassword = $BuildPassword
BuildUsername = $BuildUsername
DotNetSdkVersion = $DotNetSdkVersion
AdminPassword = $AdminPassword
}
if ($SkipWindowsUpdate) { $setupArgs['SkipWindowsUpdate'] = $true }
if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true }
# ── Run Setup-WinBuild2025.ps1 inside the VM ──────────────────────────────
# Setup may return exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) when Windows
# Update applied patches that need a reboot. We loop: reboot guest, wait for
# WinRM, reopen session, re-invoke Setup. Steps 1-5 are idempotent (~30 s);
# Step 6 picks up where the previous run left off (no leftover updates →
# exit 0). Hard cap on iterations to avoid an infinite loop.
$maxWuIterations = 10
$rebootDeadlineMin = 20
function Invoke-GuestSetup {
param([Parameter(Mandatory)] $Session,
[Parameter(Mandatory)] [string] $ScriptPath,
[Parameter(Mandatory)] [hashtable] $ScriptArgs)
Invoke-Command -Session $Session -ScriptBlock {
param($scriptPath, $scriptArgs)
Set-ExecutionPolicy Bypass -Scope Process -Force
$exitCode = 0
try {
& $scriptPath @scriptArgs
$exitCode = $LASTEXITCODE
if ($null -eq $exitCode) { $exitCode = 0 }
} catch {
Write-Error $_
$exitCode = 1
}
return $exitCode
} -ArgumentList $ScriptPath, $ScriptArgs
}
function Wait-GuestWinRMReady {
param([Parameter(Mandatory)] [string] $IP,
[Parameter(Mandatory)] $Cred,
[Parameter(Mandatory)] $SessionOptions,
[int] $TimeoutMin = 20)
$deadline = (Get-Date).AddMinutes($TimeoutMin)
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 10
try {
# Test-WSMan has no -SessionOption in PS 5.1 — use New-PSSession as probe.
$probe = New-PSSession -ComputerName $IP -Port 5986 -UseSSL `
-SessionOption $SessionOptions -Credential $Cred `
-Authentication Basic -ErrorAction Stop
Remove-PSSession $probe -ErrorAction SilentlyContinue
return $true
} catch { }
}
return $false
}
Write-Host "[Prepare] Running Setup-WinBuild2025.ps1 inside VM (this will take a while)..."
for ($iter = 1; $iter -le $maxWuIterations; $iter++) {
Write-Host "[Prepare] Setup iteration $iter/$maxWuIterations"
Write-Host "[Prepare] Output from guest:"
Write-Host "------------------------------------------------------------"
$result = $null
try {
$result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs
} catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
# VM rebooted mid-update (WU triggered OS restart before Setup could return 3010).
# Treat as reboot-required: close dead session, fall through to the reboot-wait block.
Write-Host "[Prepare] WinRM transport error — VM rebooted mid-update. Treating as reboot-required..."
Remove-PSSession $session -ErrorAction SilentlyContinue
$session = $null
$result = 3010
}
Write-Host "------------------------------------------------------------"
Write-Host "[Prepare] Iteration $iter exit code: $result"
if ($result -eq 0) { break }
if ($result -ne 3010) {
throw "Setup-WinBuild2025.ps1 exited with code $result"
}
# exit 3010 (or transport error treated as 3010) → WU needs reboot.
# Reboot guest if session still alive, wait for WinRM, reopen session, loop.
if ($iter -eq $maxWuIterations) {
throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)"
}
Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..."
if ($session) {
try {
Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue
} catch { }
Remove-PSSession $session -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 30 # let WinRM tear down before polling
Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..."
if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential `
-SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) {
throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot"
}
Write-Host "[Prepare] Guest WinRM ready, reopening session..."
$session = New-PSSession -ComputerName $VMIPAddress -Credential $credential `
-SessionOption $sessionOptions -UseSSL -Port 5986 `
-Authentication Basic -ErrorAction Stop
}
# ── Post-setup remote validation ──────────────────────────────────────────
Write-Host "[Prepare] Running post-setup validation against guest..."
$guestState = Invoke-Command -Session $session -ScriptBlock {
param($BuildUser)
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH','Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH','User')
[PSCustomObject]@{
WinRMRunning = (Get-Service WinRM -ErrorAction SilentlyContinue).Status -eq 'Running'
UserExists = [bool](Get-LocalUser -Name $BuildUser -ErrorAction SilentlyContinue)
UserIsAdmin = [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUser)
UACDisabled = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' -Name EnableLUA -ErrorAction SilentlyContinue).EnableLUA -eq 0
FirewallOff = -not (Get-NetFirewallProfile | Where-Object Enabled -eq $true)
DotNetVersion = (Get-Command dotnet -ErrorAction SilentlyContinue) | ForEach-Object { (& $_.Source --version 2>&1) }
PythonExists = Test-Path 'C:\Python\python.exe' -PathType Leaf
MSBuildExists = Test-Path $msbuildPath -PathType Leaf
CIDirsPresent = (Test-Path 'C:\CI\build') -and (Test-Path 'C:\CI\output') -and (Test-Path 'C:\CI\scripts')
}
} -ArgumentList $BuildUsername
Assert-Step 'PostSetup' 'guest WinRM Running' { $guestState.WinRMRunning }
Assert-Step 'PostSetup' "guest user '$BuildUsername' exists" { $guestState.UserExists }
Assert-Step 'PostSetup' "guest user '$BuildUsername' is admin" { $guestState.UserIsAdmin }
Assert-Step 'PostSetup' 'guest UAC disabled' { $guestState.UACDisabled }
Assert-Step 'PostSetup' 'guest firewall disabled' { $guestState.FirewallOff }
Assert-Step 'PostSetup' 'guest .NET SDK installed' { [bool]$guestState.DotNetVersion }
Assert-Step 'PostSetup' 'guest Python installed' { $guestState.PythonExists }
Assert-Step 'PostSetup' 'guest MSBuild present' { $guestState.MSBuildExists }
Assert-Step 'PostSetup' 'guest C:\CI\{build,output,scripts} present' { $guestState.CIDirsPresent }
Write-Host "[Prepare] All post-setup validations passed (.NET $($guestState.DotNetVersion))." -ForegroundColor Green
# ── Store credentials in Windows Credential Manager (optional) ────────────
if ($StoreCredential) {
Write-Host "[Prepare] Storing CI credentials in Windows Credential Manager (target: '$CredentialTarget')..."
if (-not (Get-Module -ListAvailable -Name CredentialManager)) {
Write-Host "[Prepare] CredentialManager module not found — installing (CurrentUser scope)..."
Install-Module -Name CredentialManager -Scope CurrentUser -Force -ErrorAction Stop
}
Import-Module CredentialManager -ErrorAction Stop
New-StoredCredential `
-Target $CredentialTarget `
-UserName $BuildUsername `
-Password $BuildPassword `
-Persist LocalMachine `
-ErrorAction Stop | Out-Null
Write-Host "[Prepare] Credentials stored: target='$CredentialTarget' user='$BuildUsername'." -ForegroundColor Green
}
Write-Host "[Prepare] All validations passed." -ForegroundColor Green
# ── Shutdown + snapshot prompt ─────────────────────────────────────────────
# vmrun.exe already located at script start ($vmrunExe)
# Auto-discover VMX path via 'vmrun list' while VM is still running (if not known)
if ($VMXPath -eq '') {
Write-Host "[Prepare] Discovering VMX path from running VMs..."
$runningVMs = @(& $vmrunExe list 2>&1 |
Where-Object { $_ -match '\.vmx$' } |
ForEach-Object { $_.Trim() })
if ($runningVMs.Count -eq 0) {
throw "[Prepare] No running VMs found via 'vmrun list'. Is the template VM still up?"
}
elseif ($runningVMs.Count -eq 1) {
$VMXPath = $runningVMs[0]
Write-Host "[Prepare] VMX auto-detected: $VMXPath" -ForegroundColor Green
}
else {
# Multiple VMs — prefer one under \CI\Templates\
$ciVMs = @($runningVMs | Where-Object { $_ -like '*\CI\Templates\*' })
if ($ciVMs.Count -eq 1) {
$VMXPath = $ciVMs[0]
Write-Host "[Prepare] VMX auto-detected (CI template): $VMXPath" -ForegroundColor Green
}
else {
Write-Host "[Prepare] Multiple VMs running — select one:"
for ($i = 0; $i -lt $runningVMs.Count; $i++) {
Write-Host " [$($i+1)] $($runningVMs[$i])"
}
$idx = [int](Read-Host "[Prepare] Enter number") - 1
if ($idx -lt 0 -or $idx -ge $runningVMs.Count) { throw "[Prepare] Invalid selection." }
$VMXPath = $runningVMs[$idx]
}
}
}
if (-not (Test-Path $VMXPath)) { throw "[Prepare] VMX not found on disk: '$VMXPath'" }
# Prompt
Write-Host "------------------------------------------------------------"
Write-Host ""
$choice = Read-Host "[Prepare] Shut down VM and create snapshot '$SnapshotName'? [Y/N]"
if ($choice -match '^[Yy]') {
# Send graceful shutdown
Write-Host "[Prepare] Sending graceful shutdown to VM..."
try {
Invoke-Command -Session $session -ScriptBlock { Stop-Computer -Force } -ErrorAction Stop
Write-Host "[Prepare] Shutdown command sent." -ForegroundColor Green
}
catch {
Write-Warning "[Prepare] Shutdown command failed (VM may have already shut down): $_"
}
# Close session — VM is shutting down, no further WinRM needed
Remove-PSSession $session -ErrorAction SilentlyContinue
# Wait for VM to power off (poll vmrun list)
Write-Host "[Prepare] Waiting for VM to power off (timeout 120 s)..."
$shutdownDeadline = [datetime]::UtcNow.AddSeconds(120)
$poweredOff = $false
while ([datetime]::UtcNow -lt $shutdownDeadline) {
$runningList = & $vmrunExe list 2>&1 | Out-String
if ($runningList -notmatch [regex]::Escape($VMXPath)) { $poweredOff = $true; break }
Start-Sleep -Seconds 5
Write-Host " ... waiting for shutdown..."
}
if (-not $poweredOff) { throw "[Prepare] VM did not power off within 120 s. Check VMware Workstation." }
# Check for existing snapshot with same name
$existingSnaps = & $vmrunExe listSnapshots $VMXPath 2>&1 | Out-String
$doCreate = $true
if ($existingSnaps -match [regex]::Escape($SnapshotName)) {
Write-Warning "[Prepare] Snapshot '$SnapshotName' already exists."
$delChoice = Read-Host "[Prepare] Delete existing snapshot and recreate? [Y/N]"
if ($delChoice -match '^[Yy]') {
Write-Host "[Prepare] Deleting existing snapshot '$SnapshotName'..."
& $vmrunExe deleteSnapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun deleteSnapshot exited $LASTEXITCODE." }
Write-Host "[Prepare] Existing snapshot deleted." -ForegroundColor Green
}
else {
Write-Host "[Prepare] Keeping existing snapshot — skipping creation." -ForegroundColor Yellow
$doCreate = $false
}
}
# Create snapshot
if ($doCreate) {
Write-Host "[Prepare] Creating snapshot '$SnapshotName'..." -ForegroundColor Green
& $vmrunExe -T ws snapshot $VMXPath $SnapshotName
if ($LASTEXITCODE -ne 0) { throw "[Prepare] vmrun snapshot exited $LASTEXITCODE." }
Write-Host "[Prepare] Snapshot '$SnapshotName' created successfully." -ForegroundColor Green
}
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
if ($StoreCredential) {
Write-Host " [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
}
Write-Host ""
}
else {
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM provisioning complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " MANUAL STEPS REQUIRED:"
Write-Host ""
Write-Host " 1. Shut down the VM: Start -> Shut down"
Write-Host " (VM stays on VMnet8 NAT for internet access during builds)"
Write-Host ""
Write-Host " 2. Take snapshot in VMware Workstation:"
Write-Host " VM -> Snapshot -> Take Snapshot"
Write-Host " Name it EXACTLY: $SnapshotName"
Write-Host ""
if ($StoreCredential) {
Write-Host " 3. [DONE] Credentials stored in Credential Manager (target: '$CredentialTarget')." -ForegroundColor Green
}
else {
Write-Host " 3. Store CI credentials on this HOST (run in elevated PowerShell):"
Write-Host " .\Prepare-WinBuild2025.ps1 -VMIPAddress $VMIPAddress -StoreCredential"
Write-Host " (or manually:)"
Write-Host " New-StoredCredential -Target '$CredentialTarget' ``"
Write-Host " -UserName '$BuildUsername' -Password '<your-build-password>' ``"
Write-Host " -Persist LocalMachine"
}
Write-Host ""
}
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
# Restore host TrustedHosts to its original value
if ($null -ne $prevTrustedHosts) {
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $prevTrustedHosts -Force -ErrorAction SilentlyContinue
}
Write-Host "[Prepare] Host TrustedHosts restored."
}
-659
View File
@@ -1,659 +0,0 @@
#Requires -RunAsAdministrator
#Requires -Version 5.1
<#
.SYNOPSIS
Provisions a Windows VM as a CI build template (run INSIDE the VM once).
.DESCRIPTION
This script is run ONE TIME inside the template VM before taking the
"BaseClean" snapshot. After the snapshot is taken, never modify the VM.
Installs and configures:
- Windows Updates (all available, via COM API no external module needed)
- WinRM with basic authentication (for PSSession from host)
- A dedicated local build user account
- Visual Studio Build Tools 2026 (MSBuild, MSVC, .NET desktop targets)
- .NET SDK 10.x
- Firewall rules for WinRM
NOTE: Git is NOT installed. The host clones the repository and copies
the source tree into the VM via WinRM (Option B isolation model).
NETWORK REQUIREMENT DURING SETUP
The VM must have internet access while this script runs so it can
download Windows Updates and tool installers. Configure the VM NIC
to VMnet8 (NAT) before running setup, then switch back to VMnet11
(Host-Only) BEFORE taking the BaseClean snapshot.
After this script completes:
1. Switch VM NIC from VMnet8 (NAT) VMnet11 (Host-Only) in VMware
Workstation: VM Settings Network Adapter Custom: VMnet11
2. Shut down the VM (Start Shut down)
3. In VMware Workstation: VM Snapshot Take Snapshot
Name it exactly: BaseClean
4. Power off the VM and leave it powered off permanently
.PARAMETER SkipWindowsUpdate
Skip the Windows Update step (useful if updates were already applied).
.NOTES
Invoke via Prepare-TemplateSetup.ps1 from the HOST (recommended), or
run manually from an elevated PowerShell session INSIDE the VM:
Set-ExecutionPolicy Bypass -Scope Process -Force
.\Setup-TemplateVM.ps1
#>
[CmdletBinding()]
param(
# Local username for the CI build account inside the VM
[string] $BuildUsername = 'ci_build',
# Password for the build account.
# IMPORTANT: Change this to a strong password and store it in
# Windows Credential Manager on the HOST as target "BuildVMGuest".
[string] $BuildPassword = 'CIBuild!ChangeMe2026',
# .NET SDK channel to install (should match the host SDK version)
[string] $DotNetSdkVersion = '10.0',
# Python version to install (x.y.z — must match an exact release on python.org)
[string] $PythonVersion = '3.13.3',
# Skip Windows Update (useful when updates already applied or no internet)
[switch] $SkipWindowsUpdate
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Step {
param([string]$Message)
Write-Host "`n=== $Message ===" -ForegroundColor Cyan
}
# ── Step 0: Windows Update ────────────────────────────────────────────────────
# NOTE: The Windows Update COM API (Microsoft.Update.Session) raises E_ACCESSDENIED
# when called from a WinRM/network-logon session. We work around this by running
# the actual download+install inside a Scheduled Task (SYSTEM account, full token).
if ($SkipWindowsUpdate) {
Write-Host "[Setup] Skipping Windows Update (SkipWindowsUpdate switch set)."
}
else {
Write-Step "Running Windows Update via Scheduled Task (avoids WinRM token restrictions)"
# --- worker script that runs as SYSTEM inside the scheduled task ---
$wuWorkerPath = 'C:\CI\wu_worker.ps1'
$wuResultPath = 'C:\CI\wu_result.txt'
$wuRebootPath = 'C:\CI\wu_reboot.txt'
$wuLogPath = 'C:\CI\wu_log.txt'
$wuWorker = @'
$ErrorActionPreference = 'Stop'
try {
$s = New-Object -ComObject Microsoft.Update.Session
$q = $s.CreateUpdateSearcher()
$found = $q.Search("IsInstalled=0 and Type='Software' and IsHidden=0")
$count = $found.Updates.Count
if ($count -eq 0) {
"0" | Set-Content C:\CI\wu_result.txt
"False" | Set-Content C:\CI\wu_reboot.txt
"No updates needed." | Set-Content C:\CI\wu_log.txt
} else {
"Downloading $count update(s)..." | Set-Content C:\CI\wu_log.txt
$dl = $s.CreateUpdateDownloader()
$dl.Updates = $found.Updates
$dl.Download() | Out-Null
"Installing $count update(s)..." | Add-Content C:\CI\wu_log.txt
$inst = $s.CreateUpdateInstaller()
$inst.Updates = $found.Updates
$r = $inst.Install()
[string]$r.ResultCode | Set-Content C:\CI\wu_result.txt
[string]$r.RebootRequired | Set-Content C:\CI\wu_reboot.txt
"Done. ResultCode=$($r.ResultCode) RebootRequired=$($r.RebootRequired)" | Add-Content C:\CI\wu_log.txt
}
} catch {
"ERROR: $_" | Set-Content C:\CI\wu_log.txt
"99" | Set-Content C:\CI\wu_result.txt
"False" | Set-Content C:\CI\wu_reboot.txt
}
'@
$wuWorker | Set-Content -Path $wuWorkerPath -Encoding UTF8
# Remove any leftover result files from a previous run
Remove-Item $wuResultPath, $wuRebootPath -ErrorAction SilentlyContinue
# Register scheduled task running as SYSTEM
$taskName = 'CI-WindowsUpdate'
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$wuWorkerPath`""
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
Write-Host "Scheduled task registered. Starting Windows Update..."
Start-ScheduledTask -TaskName $taskName
# Poll until the task finishes (result file written = task complete)
$timeout = [datetime]::UtcNow.AddMinutes(60)
$dotCount = 0
while (-not (Test-Path $wuResultPath)) {
if ([datetime]::UtcNow -gt $timeout) {
throw "Windows Update timed out after 60 minutes."
}
Start-Sleep -Seconds 15
$dotCount++
if ($dotCount % 4 -eq 0) { Write-Host " ... still running ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" }
}
# Read results
$resultCode = [int](Get-Content $wuResultPath -Raw).Trim()
$rebootNeeded = (Get-Content $wuRebootPath -Raw).Trim() -eq 'True'
$log = Get-Content $wuLogPath -Raw -ErrorAction SilentlyContinue
Write-Host "Windows Update log:"
Write-Host $log
# Clean up
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item $wuWorkerPath, $wuResultPath, $wuRebootPath, $wuLogPath -ErrorAction SilentlyContinue
if ($resultCode -eq 99) {
throw "Windows Update worker script failed. See log above."
}
if ($resultCode -notin @(0, 2, 3)) {
Write-Warning "Windows Update returned unexpected result code: $resultCode"
}
else {
Write-Host "Windows Update completed (ResultCode=$resultCode)."
}
if ($rebootNeeded) {
Write-Warning "*** REBOOT REQUIRED to finish installing updates. ***"
Write-Warning " Restart the VM, then re-run this script with: -SkipWindowsUpdate"
exit 3010
}
}
# ── Step 1: WinRM ─────────────────────────────────────────────────────────────
Write-Step "Configuring WinRM"
Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null
# Allow unencrypted (HTTP/5985) — acceptable for an isolated lab network.
# Migrate to HTTPS/5986 with a self-signed cert for hardened environments.
winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null
winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null
# Increase shell memory and timeout limits for long builds
winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="2048"}' | Out-Null
winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null
Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>$null
Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>$null # 2 hours
# Ensure WinRM starts automatically
Set-Service -Name WinRM -StartupType Automatic 3>$null
Start-Service WinRM 3>$null
Write-Host "WinRM configured."
# ── Step 2: Firewall ──────────────────────────────────────────────────────────
Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM"
# Disable all firewall profiles entirely. This VM lives on a Host-Only network
# (VMnet11) with no external exposure, so full disable is acceptable and avoids
# any rule-order or profile-classification issues that would block WinRM/ICMP.
Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False
Write-Host "Windows Firewall disabled on all profiles."
# ── Step 3: Build user account ────────────────────────────────────────────────
Write-Step "Creating build user account: $BuildUsername"
$existingUser = Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue
if ($existingUser) {
Write-Host "User '$BuildUsername' already exists — skipping creation."
}
else {
# Use net user instead of New-LocalUser: the LocalAccounts cmdlets have a known
# bug where SecureString deserialized over WinRM causes InvalidPasswordException.
$netResult = & net user $BuildUsername $BuildPassword /add /y 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Failed to create user '$BuildUsername': $netResult"
}
Write-Host "User '$BuildUsername' created."
}
# Always ensure password policy and Administrators membership (idempotent)
& net user $BuildUsername /passwordchg:no | Out-Null
Set-LocalUser -Name $BuildUsername -PasswordNeverExpires $true
$isMember = & net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername
if (-not $isMember) {
& net localgroup Administrators $BuildUsername /add | Out-Null
Write-Host "User '$BuildUsername' added to Administrators."
}
else {
Write-Host "User '$BuildUsername' already in Administrators."
}
# ── Step 3b: Disable UAC ──────────────────────────────────────────────────────
Write-Step "Disabling UAC (isolated lab VM)"
# EnableLUA=0 disables UAC entirely — admin processes get full token without prompt.
# LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also
# get an elevated token (avoids the filtered-token issue with runProgramInGuest).
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' `
-Name EnableLUA -Value 0 -Type DWord -Force
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' `
-Name LocalAccountTokenFilterPolicy -Value 1 -Type DWord -Force
Write-Host "UAC disabled."
# ── Step 4: CI working directories ───────────────────────────────────────────
Write-Step "Creating CI working directories"
foreach ($dir in @('C:\CI\build', 'C:\CI\output', 'C:\CI\scripts')) {
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
Write-Host "Created: $dir"
}
}
# ── Step 4b: Explorer settings ───────────────────────────────────────────────
Write-Step "Configuring Explorer defaults"
# Open Explorer to 'This PC' (Devices and Drives) instead of Quick Access.
# LaunchTo=1 = This PC, LaunchTo=2 = Quick Access (default)
$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced'
Set-ItemProperty -Path $explorerAdvKey -Name 'LaunchTo' -Value 1 -Type DWord -Force
# Also apply to the Default User profile so ci_build and any new user inherits it.
$defaultHive = 'C:\Users\Default\NTUSER.DAT'
$mountName = 'TempDefaultUser'
if (Test-Path $defaultHive) {
reg load "HKU\$mountName" $defaultHive | Out-Null
$defKey = "Registry::HKU\$mountName\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced"
if (-not (Test-Path $defKey)) { New-Item -Path $defKey -Force | Out-Null }
Set-ItemProperty -Path $defKey -Name 'LaunchTo' -Value 1 -Type DWord -Force
[gc]::Collect()
reg unload "HKU\$mountName" | Out-Null
Write-Host "Explorer: 'This PC' set for current user and default profile."
}
else {
Write-Host "Explorer: 'This PC' set for current user (default hive not found)."
}
# ── Step 5: Disable Windows Defender ────────────────────────────────────────
Write-Step "Disabling Windows Defender real-time protection"
# Defender scanning significantly slows compilation and NuGet restore.
# On an isolated CI template VM this trade-off is acceptable.
$defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue
if ($defSvc) {
Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null
Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null
Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null
Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null
# Exclude CI build paths from any remaining scanning
Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null
Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null
Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null
Write-Host "Windows Defender real-time protection disabled."
}
else {
Write-Host "Windows Defender service not found — skipping."
}
# Disable via Group Policy registry key — survives Defender auto-updates and reboots.
# This is the same key that SCCM/GPO uses in enterprise environments.
$defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender'
if (-not (Test-Path $defenderKey)) {
New-Item -Path $defenderKey -Force | Out-Null
}
Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force
# Also disable via the Windows Security Center policy subkey
$rtKey = "$defenderKey\Real-Time Protection"
if (-not (Test-Path $rtKey)) {
New-Item -Path $rtKey -Force | Out-Null
}
Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force
Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force
Write-Host "Defender GPO registry keys written (persistent across updates)."
# ── Step 6: Install .NET SDK ─────────────────────────────────────────────────
Write-Step "Installing .NET SDK $DotNetSdkVersion"
$dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue
if ($dotnetInstalled) {
$installedVersion = dotnet --version
Write-Host ".NET SDK already installed: $installedVersion"
}
else {
Write-Host "Downloading dotnet-install.ps1..."
$dotnetInstallScript = "$env:TEMP\dotnet-install.ps1"
Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' `
-OutFile $dotnetInstallScript -UseBasicParsing
Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..."
& $dotnetInstallScript `
-Channel $DotNetSdkVersion `
-InstallDir 'C:\dotnet' `
-NoPath
# Add to system PATH
$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($currentPath -notlike '*C:\dotnet*') {
[System.Environment]::SetEnvironmentVariable(
'PATH',
"$currentPath;C:\dotnet",
'Machine'
)
}
$env:PATH = $env:PATH + ';C:\dotnet'
Write-Host ".NET SDK installed: $(dotnet --version)"
}
# ── Step 7: Install Python ───────────────────────────────────────────────────
Write-Step "Installing Python $PythonVersion"
$pythonExe = 'C:\Python\python.exe'
if (Test-Path $pythonExe) {
Write-Host "Python already installed: $(& $pythonExe --version 2>&1)"
}
else {
$pyInstallerUrl = "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe"
$pyInstallerPath = 'C:\CI\python_installer.exe'
Write-Host "Downloading Python $PythonVersion installer..."
Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing
Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..."
$pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @(
'/quiet',
'InstallAllUsers=1',
'PrependPath=1',
'Include_test=0',
'Include_doc=0',
'TargetDir=C:\Python'
) -Wait -PassThru
Remove-Item $pyInstallerPath -ErrorAction SilentlyContinue
if ($pyProc -and $pyProc.ExitCode -ne 0) {
throw "Python installation failed (exit $($pyProc.ExitCode))."
}
# Refresh PATH for subsequent steps
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
Write-Host "Python installed: $(python --version 2>&1)"
}
# ── Step 8: Install Visual Studio Build Tools 2022 ───────────────────────────
Write-Step "Installing Visual Studio Build Tools 2026"
$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe'
if (Test-Path $msbuildPath) {
Write-Host "VS Build Tools 2026 already installed."
}
else {
$vsInstallerUrl = 'https://aka.ms/vs/stable/vs_buildtools.exe'
$vsInstallerPath = 'C:\CI\vs_BuildTools.exe'
$vsResultPath = 'C:\CI\vs_result.txt'
$vsLogPath = 'C:\CI\vs_log.txt'
Write-Host "Downloading VS Build Tools installer..."
Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy
Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing
# Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet).
# SYSTEM account cannot execute them without this unblock step.
Unblock-File -Path $vsInstallerPath -ErrorAction SilentlyContinue
# Verify the download produced a valid Windows PE file (MZ header)
$mzBytes = [System.IO.File]::ReadAllBytes($vsInstallerPath) | Select-Object -First 2
if (-not ($mzBytes[0] -eq 0x4D -and $mzBytes[1] -eq 0x5A)) {
$fileSize = (Get-Item $vsInstallerPath).Length
throw "VS installer download appears invalid (size=$fileSize, not an EXE). Check the URL: $vsInstallerUrl"
}
Write-Host "Installer verified OK ($((Get-Item $vsInstallerPath).Length) bytes)."
# VS installer fails under WinRM (network logon token). Run via Scheduled Task as SYSTEM.
$vsWorkerPath = 'C:\CI\vs_worker.ps1'
$vsWorker = @"
# SYSTEM account may have no TEMP set — use C:\Windows\Temp
`$env:TEMP = 'C:\Windows\Temp'
`$env:TMP = 'C:\Windows\Temp'
# Clean any corrupt VS installer cache left by previous failed attempts
Remove-Item 'C:\ProgramData\Microsoft\VisualStudio\Packages' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\Windows\Temp\vs*' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\Windows\Temp\dd_*' -Force -ErrorAction SilentlyContinue
`$result = try {
`$proc = Start-Process -FilePath '$vsInstallerPath' -ArgumentList @(
'--quiet','--wait','--norestart','--nocache',
'--installPath "C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools"',
'--add','Microsoft.VisualStudio.Workload.VCTools',
'--includeRecommended',
'--add','Microsoft.VisualStudio.Workload.MSBuildTools',
'--add','Microsoft.VisualStudio.Workload.ManagedDesktopBuildTools',
'--add','Microsoft.VisualStudio.Component.NuGet.BuildTools',
'--add','Microsoft.Net.Component.4.8.SDK',
'--add','Microsoft.Net.Component.4.7.2.TargetingPack'
) -Wait -PassThru
if (`$proc) { [string]`$proc.ExitCode } else { '99' }
} catch {
"99: `$_"
}
if (-not `$result) { `$result = '99' }
`$result | Set-Content '$vsResultPath' -Encoding UTF8
"@
$vsWorker | Set-Content -Path $vsWorkerPath -Encoding UTF8
Remove-Item $vsResultPath -ErrorAction SilentlyContinue
$taskName = 'CI-VSBuildTools'
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$vsWorkerPath`""
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
Write-Host "Installing VS Build Tools via Scheduled Task (this may take 15-30 minutes)..."
Start-ScheduledTask -TaskName $taskName
$timeout = [datetime]::UtcNow.AddMinutes(60)
$dots = 0
while (-not (Test-Path $vsResultPath)) {
if ([datetime]::UtcNow -gt $timeout) { throw "VS Build Tools installation timed out." }
# If task already finished without writing the result file, the worker itself crashed
$taskInfo = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($taskInfo -and $taskInfo.State -eq 'Ready') {
$lastResult = (Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue).LastTaskResult
throw "VS worker task exited prematurely (LastTaskResult=0x$('{0:X}' -f $lastResult)). Check C:\CI\vs_worker.ps1 for syntax errors."
}
Start-Sleep -Seconds 20
$dots++
if ($dots % 3 -eq 0) { Write-Host " ... still installing ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" }
}
$rawResult = Get-Content $vsResultPath -Raw -ErrorAction SilentlyContinue
$rawResult = if ($rawResult) { $rawResult.Trim() } else { '99' }
# Result file may contain "0", "3010", or "99: <error message>"
$vsExit = if ($rawResult -match '^\d+$') { [int]$rawResult } else { 99 }
$vsMsg = if ($vsExit -eq 99) { $rawResult } else { '' }
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item $vsWorkerPath, $vsResultPath, $vsInstallerPath -ErrorAction SilentlyContinue
if ($vsExit -notin @(0, 3010)) {
throw "VS Build Tools installation failed (exit $vsExit). $vsMsg"
}
Write-Host "VS Build Tools 2026 installed (exit code $vsExit)."
}
# Add MSBuild to system PATH
$msbuildDir = Split-Path $msbuildPath -Parent
$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
if ($currentPath -notlike "*$msbuildDir*") {
[System.Environment]::SetEnvironmentVariable(
'PATH',
"$currentPath;$msbuildDir",
'Machine'
)
Write-Host "MSBuild added to system PATH."
}
# ── Step 9: Verify toolchain ───────────────────────────────────────────────
Write-Step "Verifying toolchain"
$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('PATH', 'User')
$allOk = $true
# Helper: resolve a binary — tries hardcoded path first, then Get-Command (PATH)
function Resolve-Tool {
param([string]$HardcodedPath, [string]$CommandName)
if ($HardcodedPath -and (Test-Path $HardcodedPath -PathType Leaf)) {
return $HardcodedPath
}
$found = Get-Command $CommandName -ErrorAction SilentlyContinue
if ($found) { return $found.Source }
return $null
}
# dotnet
$dotnetBin = Resolve-Tool '' 'dotnet'
if ($dotnetBin) {
$v = & $dotnetBin --version 2>&1 | Select-Object -First 1
Write-Host " [OK] dotnet: $v ($dotnetBin)"
} else {
Write-Warning " [FAIL] dotnet not found"
$allOk = $false
}
# python
$pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python'
if ($pythonBin) {
$v = & $pythonBin --version 2>&1 | Select-Object -First 1
Write-Host " [OK] python: $v ($pythonBin)"
} else {
Write-Warning " [FAIL] python not found"
$allOk = $false
}
# msbuild
$msbuildBin = Resolve-Tool $msbuildPath 'msbuild'
if ($msbuildBin) {
$v = & $msbuildBin -version 2>&1 | Select-Object -First 1
Write-Host " [OK] msbuild: $v ($msbuildBin)"
} else {
Write-Warning " [FAIL] msbuild not found at '$msbuildPath' and not in PATH"
$allOk = $false
}
if (-not $allOk) {
Write-Warning "Some tools failed verification. Review above and rerun if needed."
}
# ── Cleanup ───────────────────────────────────────────────────────────────────
Write-Step "Cleaning up disk"
# 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags
# First register the flags via /sageset:1 in the registry (silent, no UI)
$cleanKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches'
$sageset = 1
$cleanCategories = @(
'Active Setup Temp Folders',
'BranchCache',
'Downloaded Program Files',
'Internet Cache Files',
'Memory Dump Files',
'Old ChkDsk Files',
'Previous Installations',
'Recycle Bin',
'Service Pack Cleanup',
'Setup Log Files',
'System error memory dump files',
'System error minidump files',
'Temporary Files',
'Temporary Setup Files',
'Thumbnail Cache',
'Update Cleanup',
'Upgrade Discarded Files',
'Windows Defender',
'Windows Error Reporting Archive Files',
'Windows Error Reporting Files',
'Windows Error Reporting Queue Files',
'Windows Error Reporting Temp Files',
'Windows ESD installation files',
'Windows Upgrade Log Files'
)
foreach ($cat in $cleanCategories) {
$keyPath = "$cleanKey\$cat"
if (Test-Path $keyPath) {
Set-ItemProperty -Path $keyPath -Name "StateFlags$('{0:D4}' -f $sageset)" -Value 2 -Type DWord -Force -ErrorAction SilentlyContinue
}
}
Write-Host "Running cleanmgr /sagerun:$sageset (may take a few minutes)..."
$proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" -Wait -PassThru
Write-Host "cleanmgr exited (code $($proc.ExitCode))."
# 2. Clear Windows Update cache
Write-Host "Stopping Windows Update service..."
Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue
Remove-Item 'C:\Windows\SoftwareDistribution\Download\*' -Recurse -Force -ErrorAction SilentlyContinue
Start-Service -Name wuauserv -ErrorAction SilentlyContinue
Write-Host "Windows Update download cache cleared."
# 3. Clear CBS / component store logs
Remove-Item 'C:\Windows\Logs\CBS\*' -Force -ErrorAction SilentlyContinue
# 4. Clear TEMP folders
Remove-Item 'C:\Windows\Temp\*' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue
# 5. Clear Prefetch
Remove-Item 'C:\Windows\Prefetch\*' -Force -ErrorAction SilentlyContinue
# 6. Run DISM component store cleanup (removes superseded update components)
Write-Host "Running DISM component store cleanup (StartComponentCleanup)..."
$dism = Start-Process -FilePath 'dism.exe' `
-ArgumentList '/Online','/Cleanup-Image','/StartComponentCleanup','/ResetBase' `
-Wait -PassThru
Write-Host "DISM exited (code $($dism.ExitCode))."
Write-Host "Disk cleanup complete."
# ── Done ──────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " Template VM setup complete!" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " MANDATORY NEXT STEPS:"
Write-Host ""
Write-Host " 1. SWITCH NIC TO HOST-ONLY (critical before snapshot!):"
Write-Host " VMware Workstation -> VM -> Settings -> Network Adapter"
Write-Host " Change from: VMnet8 (NAT)"
Write-Host " Change to: Custom: VMnet11"
Write-Host ""
Write-Host " 2. Shut down this VM: Start -> Shut down"
Write-Host ""
Write-Host " 3. Take snapshot in VMware Workstation:"
Write-Host " VM -> Snapshot -> Take Snapshot"
Write-Host " Name it EXACTLY: BaseClean"
Write-Host ""
Write-Host " 4. Leave VM powered off permanently."
Write-Host ""
Write-Host " 5. On the HOST - store credentials in Windows Credential Manager:"
Write-Host " New-StoredCredential -Target 'BuildVMGuest' -UserName '$BuildUsername' ``"
Write-Host " -Password '$BuildPassword' -Persist LocalMachine"
Write-Host " (Run in an elevated PowerShell with CredentialManager module)"
Write-Host ""
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Validates that Deploy-WinBuild2025.ps1 has correctly configured a guest VM.
.DESCRIPTION
Connects to the VM via WinRM (HTTP/Basic) and runs all checks that
Setup-WinBuild2025.ps1 Steps 1/2/3/4b/5b/5c assert as validation-only.
Use this after Deploy completes and before running Prepare/Setup.
.PARAMETER VMIPAddress
IP address of the guest VM on VMnet8 (e.g. 192.168.79.129).
.PARAMETER AdminUsername
Admin account inside the VM (default: Administrator).
.PARAMETER AdminPassword
Password for the admin account. Prompted if omitted.
.EXAMPLE
.\Validate-DeployState.ps1 -VMIPAddress 192.168.79.129
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $VMIPAddress,
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $AdminPassword) {
$secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString
$AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
}
$cred = New-Object System.Management.Automation.PSCredential(
$AdminUsername,
(ConvertTo-SecureString $AdminPassword -AsPlainText -Force)
)
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
try {
$cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) {
$newHosts = if ($cur) { "$cur,$VMIPAddress" } else { $VMIPAddress }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newHosts -Force
}
Write-Host "`nValidating Deploy state on $VMIPAddress..." -ForegroundColor Cyan
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
function Chk {
param([string]$Name, [scriptblock]$Test)
try { $ok = [bool](& $Test); $err = '' }
catch { $ok = $false; $err = $_.Exception.Message }
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
}
# ── Firewall ─────────────────────────────────────────────────────────
foreach ($p in Get-NetFirewallProfile) {
$n = $p.Name; $e = $p.Enabled
Chk "Firewall $n disabled" { $e -eq $false }
}
# ── Defender ─────────────────────────────────────────────────────────
Chk 'Defender GPO DisableAntiSpyware=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' `
-Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1
}
# ── WinRM ─────────────────────────────────────────────────────────────
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' {
(Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false'
}
Chk 'WinRM Auth/Basic=true' {
(Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true'
}
Chk 'WinRM MaxMemoryPerShellMB >= 2048' {
[int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048
}
# ── UAC ───────────────────────────────────────────────────────────────
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Chk 'UAC EnableLUA=0' {
(Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0
}
Chk 'UAC LocalAccountTokenFilterPolicy=1' {
(Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1
}
# ── Explorer ──────────────────────────────────────────────────────────
Chk 'Explorer LaunchTo=1 (HKCU)' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' `
-Name LaunchTo -EA Stop).LaunchTo -eq 1
}
# ── UX hardening ─────────────────────────────────────────────────────
Chk 'NoLockScreen=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' `
-Name NoLockScreen -EA Stop).NoLockScreen -eq 1
}
Chk 'Server Manager policy DoNotOpenAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' `
-Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1
}
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'Server Manager task Disabled' {
$t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' `
-TaskName 'ServerManager' -EA SilentlyContinue
(-not $t) -or ($t.State -eq 'Disabled')
}
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'DisableCAD=1 (Policies\System)' {
(Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1
}
Chk 'OOBE DisablePrivacyExperience=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' `
-Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1
}
Chk 'DataCollection AllowTelemetry=0' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' `
-Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0
}
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' {
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
# ── Windows Update GPO + services ─────────────────────────────────────
Chk 'WU GPO NoAutoUpdate=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `
-Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1
}
Chk 'WU GPO DisableWindowsUpdateAccess=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
-Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1
}
Chk 'wuauserv StartType=Manual' { (Get-Service wuauserv -EA Stop).StartType -eq 'Manual' }
Chk 'UsoSvc StartType=Manual' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Manual' }
return $results.ToArray()
}
Write-Host ''
$failed = 0
foreach ($r in $checks) {
if ($r.Pass) {
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
} else {
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
$failed++
}
}
Write-Host ''
if ($failed -eq 0) {
Write-Host "All $($checks.Count) Deploy checks passed." -ForegroundColor Green
exit 0
} else {
Write-Host "$failed / $($checks.Count) Deploy checks FAILED." -ForegroundColor Red
exit 1
}
} finally {
Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue
}
+371
View File
@@ -0,0 +1,371 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Validates full post-Setup state of a template VM (Deploy + Setup).
.DESCRIPTION
Connects to the VM via WinRM (HTTP/Basic) and runs all checks for the
final BaseClean snapshot state: Deploy OS hardening + Setup CI toolchain.
Run this after Prepare-WinBuild2025.ps1 completes (exit 0) to confirm
the VM is ready for snapshot and production use.
.PARAMETER VMIPAddress
IP address of the guest VM on VMnet8 (e.g. 192.168.79.129).
.PARAMETER AdminUsername
Admin account inside the VM (default: Administrator).
.PARAMETER AdminPassword
Password for the admin account. Prompted if omitted.
.PARAMETER BuildUsername
CI build account created by Setup (default: ci_build).
.PARAMETER DotNetChannel
Expected .NET SDK channel prefix (default: 10.0).
.PARAMETER PythonVersion
Expected exact Python version string (default: 3.13.3).
.PARAMETER Remediate
If set, automatically applies fixes for known remediable failures
(AutoAdminLogon, wuauserv/UsoSvc StartType=Disabled) then re-runs all checks.
.EXAMPLE
.\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129
.EXAMPLE
.\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129 -Remediate
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $VMIPAddress,
[string] $AdminUsername = 'Administrator',
[string] $AdminPassword,
[string] $BuildUsername = 'ci_build',
[string] $DotNetChannel = '10.0',
[string] $PythonVersion = '3.13.3',
[switch] $Remediate
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not $AdminPassword) {
$secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString
$AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure))
}
$cred = New-Object System.Management.Automation.PSCredential(
$AdminUsername,
(ConvertTo-SecureString $AdminPassword -AsPlainText -Force)
)
$so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
$prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
try {
$cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value
if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) {
$newHosts = if ($cur) { "$cur,$VMIPAddress" } else { $VMIPAddress }
Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newHosts -Force
}
Write-Host "`nValidating full Setup state on $VMIPAddress..." -ForegroundColor Cyan
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
param($BuildUsername, $DotNetChannel, $PythonVersion)
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
function Chk {
param([string]$Name, [scriptblock]$Test)
try { $ok = [bool](& $Test); $err = '' }
catch { $ok = $false; $err = $_.Exception.Message }
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
}
# ════════════════════════════════════════════════════════════════════
# DEPLOY checks (same as Validate-DeployState.ps1)
# ════════════════════════════════════════════════════════════════════
# Firewall
foreach ($p in Get-NetFirewallProfile) {
$n = $p.Name; $e = $p.Enabled
Chk "Firewall $n disabled" { $e -eq $false }
}
# Defender
Chk 'Defender GPO DisableAntiSpyware=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' `
-Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1
}
# WinRM
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' {
(Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false'
}
Chk 'WinRM Auth/Basic=true' {
(Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true'
}
Chk 'WinRM MaxMemoryPerShellMB >= 2048' {
[int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048
}
# UAC
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Chk 'UAC EnableLUA=0' {
(Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0
}
Chk 'UAC LocalAccountTokenFilterPolicy=1' {
(Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1
}
# Explorer
Chk 'Explorer LaunchTo=1 (HKCU)' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' `
-Name LaunchTo -EA Stop).LaunchTo -eq 1
}
# UX hardening
Chk 'NoLockScreen=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' `
-Name NoLockScreen -EA Stop).NoLockScreen -eq 1
}
Chk 'Server Manager policy DoNotOpenAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' `
-Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1
}
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'Server Manager task Disabled' {
$t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' `
-TaskName 'ServerManager' -EA SilentlyContinue
(-not $t) -or ($t.State -eq 'Disabled')
}
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' {
(Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' `
-Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1
}
Chk 'DisableCAD=1 (Policies\System)' {
(Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1
}
Chk 'OOBE DisablePrivacyExperience=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' `
-Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1
}
Chk 'DataCollection AllowTelemetry=0' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' `
-Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0
}
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' {
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop
$wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator'
}
# ════════════════════════════════════════════════════════════════════
# SETUP checks (post-Prepare state)
# ════════════════════════════════════════════════════════════════════
# CI build user
Chk "user $BuildUsername exists" {
$null -ne (Get-LocalUser -Name $BuildUsername -EA Stop)
}
Chk "user $BuildUsername enabled" {
(Get-LocalUser -Name $BuildUsername -EA Stop).Enabled
}
Chk "user $BuildUsername PasswordNeverExpires" {
(Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null
}
Chk "user $BuildUsername member of Administrators" {
[bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername)
}
# CI directories
foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') {
Chk "$dir exists" { Test-Path $dir -PathType Container }
}
# Windows Update permanently disabled
Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' }
Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' }
Chk 'WU GPO NoAutoUpdate=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' `
-Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1
}
Chk 'WU GPO DisableWindowsUpdateAccess=1' {
(Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' `
-Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1
}
# Toolchain
Chk ".NET SDK channel $DotNetChannel" {
$v = (& dotnet --version 2>&1) -as [string]
$v -and $v.StartsWith($DotNetChannel)
}
Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' }
Chk "Python version $PythonVersion" {
$v = (& 'C:\Python\python.exe' --version 2>&1) -as [string]
$v -and $v.Trim() -eq "Python $PythonVersion"
}
Chk 'MSBuild.exe present' {
$msb = Get-Command msbuild -ErrorAction SilentlyContinue
$msb -and (Test-Path $msb.Source)
}
# No leftover installer files in C:\CI root
Chk 'no leftover installer scripts in C:\CI' {
$leftovers = Get-ChildItem 'C:\CI' -File -EA SilentlyContinue |
Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' }
$leftovers.Count -eq 0
}
return $results.ToArray()
} -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion
Write-Host ''
$failed = 0
$deployCnt = 0
$setupCnt = 0
$inSetup = $false
foreach ($r in $checks) {
if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") {
Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan
$inSetup = $true
}
if ($r.Pass) {
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
} else {
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
$failed++
}
}
Write-Host ''
if ($failed -eq 0) {
Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green
exit 0
}
if (-not $Remediate) {
Write-Host "$failed / $($checks.Count) checks FAILED." -ForegroundColor Red
Write-Host "Re-run with -Remediate to auto-fix known issues." -ForegroundColor Yellow
exit 1
}
# ── Remediation pass ─────────────────────────────────────────────────────
$failedNames = @($checks | Where-Object { -not $_.Pass } | Select-Object -ExpandProperty Name)
Write-Host "Applying remediation for $($failedNames.Count) failed check(s)..." -ForegroundColor Yellow
Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
param([string[]] $FailedNames, [string] $AdminPassword)
if ($FailedNames -contains 'AutoAdminLogon=1, DefaultUserName=Administrator') {
$wl = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'
Set-ItemProperty $wl AutoAdminLogon '1' -Type String
Set-ItemProperty $wl DefaultUserName 'Administrator' -Type String
Set-ItemProperty $wl DefaultDomainName '.' -Type String
Set-ItemProperty $wl DefaultPassword $AdminPassword -Type String
Write-Host " [FIX] AutoAdminLogon keys written."
}
if ($FailedNames -contains 'wuauserv StartType=Disabled') {
Set-Service -Name wuauserv -StartupType Disabled -ErrorAction SilentlyContinue
Write-Host " [FIX] wuauserv set to Disabled."
}
if ($FailedNames -contains 'UsoSvc StartType=Disabled') {
Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue
Write-Host " [FIX] UsoSvc set to Disabled."
}
} -ArgumentList $failedNames, $AdminPassword
# ── Re-run checks ─────────────────────────────────────────────────────────
Write-Host "`nRe-validating after remediation..." -ForegroundColor Cyan
$checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred `
-Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock {
param($BuildUsername, $DotNetChannel, $PythonVersion)
$results = [System.Collections.Generic.List[PSCustomObject]]::new()
function Chk {
param([string]$Name, [scriptblock]$Test)
try { $ok = [bool](& $Test); $err = '' }
catch { $ok = $false; $err = $_.Exception.Message }
$results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err })
}
foreach ($p in Get-NetFirewallProfile) { $n=$p.Name;$e=$p.Enabled; Chk "Firewall $n disabled" { $e -eq $false } }
Chk 'Defender GPO DisableAntiSpyware=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' -Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1 }
Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' }
Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' }
Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' { (Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false' }
Chk 'WinRM Auth/Basic=true' { (Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true' }
Chk 'WinRM MaxMemoryPerShellMB >= 2048' { [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048 }
$polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Chk 'UAC EnableLUA=0' { (Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0 }
Chk 'UAC LocalAccountTokenFilterPolicy=1' { (Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1 }
Chk 'Explorer LaunchTo=1 (HKCU)' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' -Name LaunchTo -EA Stop).LaunchTo -eq 1 }
Chk 'NoLockScreen=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Name NoLockScreen -EA Stop).NoLockScreen -eq 1 }
Chk 'Server Manager policy DoNotOpenAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' -Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1 }
Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 }
Chk 'Server Manager task Disabled' { $t=Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' -TaskName 'ServerManager' -EA SilentlyContinue; (-not $t) -or ($t.State -eq 'Disabled') }
Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 }
Chk 'DisableCAD=1 (Policies\System)' { (Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1 }
Chk 'OOBE DisablePrivacyExperience=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' -Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1 }
Chk 'DataCollection AllowTelemetry=0' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' -Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0 }
Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' { $wl=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop; $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' }
Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' }
Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' }
Chk 'WU GPO NoAutoUpdate=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1 }
Chk 'WU GPO DisableWindowsUpdateAccess=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' -Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1 }
Chk "user $BuildUsername exists" { $null -ne (Get-LocalUser -Name $BuildUsername -EA Stop) }
Chk "user $BuildUsername enabled" { (Get-LocalUser -Name $BuildUsername -EA Stop).Enabled }
Chk "user $BuildUsername PasswordNeverExpires" { (Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null }
Chk "user $BuildUsername member of Administrators" { [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) }
foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') { Chk "$dir exists" { Test-Path $dir -PathType Container } }
Chk ".NET SDK channel $DotNetChannel" { $v=(& dotnet --version 2>&1) -as [string]; $v -and $v.StartsWith($DotNetChannel) }
Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' }
Chk "Python version $PythonVersion" { $v=(& 'C:\Python\python.exe' --version 2>&1) -as [string]; $v -and $v.Trim() -eq "Python $PythonVersion" }
Chk 'MSBuild.exe present' { $msb=Get-Command msbuild -EA SilentlyContinue; $msb -and (Test-Path $msb.Source) }
Chk 'no leftover installer scripts in C:\CI' { $l=Get-ChildItem 'C:\CI' -File -EA SilentlyContinue | Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' }; $l.Count -eq 0 }
return $results.ToArray()
} -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion
Write-Host ''
$failed = 0
$inSetup = $false
foreach ($r in $checks) {
if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") {
Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan
$inSetup = $true
}
if ($r.Pass) {
Write-Host " [OK] $($r.Name)" -ForegroundColor Green
} else {
$detail = if ($r.Err) { " ($($r.Err))" } else { '' }
Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red
$failed++
}
}
Write-Host ''
if ($failed -eq 0) {
Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green
exit 0
} else {
Write-Host "$failed / $($checks.Count) checks still FAILED after remediation." -ForegroundColor Red
exit 1
}
} finally {
Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue
}
+195
View File
@@ -0,0 +1,195 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Windows Server 2025 unattended install template.
Double-brace placeholders are substituted by Deploy-WinBuild2025.ps1.
Boot NIC is e1000e (broad compat). VMware Tools install brings
vmxnet3 driver; the host script switches the VMX to vmxnet3
after Tools install completes.
Disk layout (UEFI/GPT, 80 GB):
Part 1: EFI 100 MB FAT32 S:
Part 2: MSR 16 MB (no fmt)
Part 3: Primary rest NTFS C:
No Recovery partition (per user choice).
Image index 2 = "Windows Server 2025 SERVERSTANDARD" (Desktop Experience)
on the VOL ISO. Verify with:
dism /Get-WimInfo /WimFile:D:\sources\install.wim
-->
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<SetupUILanguage>
<UILanguage>{{LOCALE_OS}}</UILanguage>
</SetupUILanguage>
<InputLocale>{{KEYBOARD}}</InputLocale>
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
<UILanguage>{{LOCALE_OS}}</UILanguage>
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
<UserLocale>{{LOCALE_USER}}</UserLocale>
</component>
<component name="Microsoft-Windows-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>EFI</Type>
<Size>100</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>2</Order>
<Type>MSR</Type>
<Size>16</Size>
</CreatePartition>
<CreatePartition wcm:action="add">
<Order>3</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Format>FAT32</Format>
<Label>System</Label>
<Letter>S</Letter>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>2</Order>
<PartitionID>2</PartitionID>
</ModifyPartition>
<ModifyPartition wcm:action="add">
<Order>3</Order>
<PartitionID>3</PartitionID>
<Format>NTFS</Format>
<Label>Windows</Label>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/INDEX</Key>
<Value>{{IMAGE_INDEX}}</Value>
</MetaData>
</InstallFrom>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>3</PartitionID>
</InstallTo>
</OSImage>
</ImageInstall>
<UserData>
<AcceptEula>true</AcceptEula>
<FullName>CI</FullName>
<Organization>CI Lab</Organization>
<ProductKey>
<Key>{{PRODUCT_KEY}}</Key>
<WillShowUI>OnError</WillShowUI>
</ProductKey>
</UserData>
<UseConfigurationSet>false</UseConfigurationSet>
<DynamicUpdate>
<Enable>false</Enable>
<WillShowUI>Never</WillShowUI>
</DynamicUpdate>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<ComputerName>{{COMPUTER_NAME}}</ComputerName>
<TimeZone>{{TIMEZONE}}</TimeZone>
<RegisteredOwner>CI</RegisteredOwner>
<RegisteredOrganization>CI Lab</RegisteredOrganization>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<InputLocale>{{KEYBOARD}}</InputLocale>
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
<UILanguage>{{LOCALE_OS}}</UILanguage>
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
<UserLocale>{{LOCALE_USER}}</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<UserAccounts>
<AdministratorPassword>
<Value>{{ADMIN_PWD}}</Value>
<PlainText>true</PlainText>
</AdministratorPassword>
</UserAccounts>
<AutoLogon>
<Username>{{ADMIN_USER}}</Username>
<Password>
<Value>{{ADMIN_PWD}}</Value>
<PlainText>true</PlainText>
</Password>
<Enabled>true</Enabled>
<LogonCount>5</LogonCount>
</AutoLogon>
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
<TimeZone>{{TIMEZONE}}</TimeZone>
<RegisteredOwner>CI</RegisteredOwner>
<RegisteredOrganization>CI Lab</RegisteredOrganization>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>cmd /c for %d in (C D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist %d:\post-install.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\post-install.ps1 ^&gt; C:\Windows\Temp\post-install.log 2^&gt;^&amp;1</CommandLine>
<Description>Run post-install script from autounattend CD</Description>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>