From 2f6add1fab1a0a23c14ce9688ff109a817736729 Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 9 May 2026 00:05:49 +0200 Subject: [PATCH] feat: populate root Setup-TemplateVM.ps1 (convenience copy of template/Setup-TemplateVM.ps1) --- README.md | 6 +- Setup-TemplateVM.ps1 | 660 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 663 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fa0ccf9..ecf7465 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,11 @@ Ogni build gira in una VM Windows Server 2025 **usa e getta**, ripristinata dall │ ├── Get-BuildArtifacts.ps1 # Copia artifacts dall'host │ └── Remove-BuildVM.ps1 # Stop + rimozione VM clone │ +├── Setup-TemplateVM.ps1 # Installazione toolchain nella VM (copia conveniente) +│ ├── template/ -│ ├── Prepare-TemplateSetup.ps1 # Provisioning automatico template VM -│ └── Setup-TemplateVM.ps1 # Installazione toolchain nella VM +│ ├── Prepare-TemplateSetup.ps1 # Provisioning automatico template VM (HOST-side) +│ └── Setup-TemplateVM.ps1 # Installazione toolchain nella VM (guest-side) │ ├── gitea/ │ ├── workflows/ diff --git a/Setup-TemplateVM.ps1 b/Setup-TemplateVM.ps1 index 5f28270..b854b5a 100644 --- a/Setup-TemplateVM.ps1 +++ b/Setup-TemplateVM.ps1 @@ -1 +1,659 @@ - \ No newline at end of file +#Requires -RunAsAdministrator +#Requires -Version 5.1 +<# +.SYNOPSIS + Provisions a Windows VM as a CI build template (run INSIDE the VM once). + +.DESCRIPTION + This script is run ONE TIME inside the template VM before taking the + "BaseClean" snapshot. After the snapshot is taken, never modify the VM. + + Installs and configures: + - Windows Updates (all available, via COM API — no external module needed) + - WinRM with basic authentication (for PSSession from host) + - A dedicated local build user account + - Visual Studio Build Tools 2026 (MSBuild, MSVC, .NET desktop targets) + - .NET SDK 10.x + - Firewall rules for WinRM + + NOTE: Git is NOT installed. The host clones the repository and copies + the source tree into the VM via WinRM (Option B isolation model). + + ╔══════════════════════════════════════════════════════════════════════╗ + ║ NETWORK REQUIREMENT DURING SETUP ║ + ║ The VM must have internet access while this script runs so it can ║ + ║ download Windows Updates and tool installers. Configure the VM NIC ║ + ║ to VMnet8 (NAT) before running setup, then switch back to VMnet11 ║ + ║ (Host-Only) BEFORE taking the BaseClean snapshot. ║ + ╚══════════════════════════════════════════════════════════════════════╝ + + After this script completes: + 1. Switch VM NIC from VMnet8 (NAT) → VMnet11 (Host-Only) in VMware + Workstation: VM → Settings → Network Adapter → Custom: VMnet11 + 2. Shut down the VM (Start → Shut down) + 3. In VMware Workstation: VM → Snapshot → Take Snapshot + Name it exactly: BaseClean + 4. Power off the VM and leave it powered off permanently + +.PARAMETER SkipWindowsUpdate + Skip the Windows Update step (useful if updates were already applied). + +.NOTES + Invoke via Prepare-TemplateSetup.ps1 from the HOST (recommended), or + run manually from an elevated PowerShell session INSIDE the VM: + Set-ExecutionPolicy Bypass -Scope Process -Force + .\Setup-TemplateVM.ps1 +#> +[CmdletBinding()] +param( + # Local username for the CI build account inside the VM + [string] $BuildUsername = 'ci_build', + + # Password for the build account. + # IMPORTANT: Change this to a strong password and store it in + # Windows Credential Manager on the HOST as target "BuildVMGuest". + [string] $BuildPassword = 'CIBuild!ChangeMe2026', + + # .NET SDK channel to install (should match the host SDK version) + [string] $DotNetSdkVersion = '10.0', + + # Python version to install (x.y.z — must match an exact release on python.org) + [string] $PythonVersion = '3.13.3', + + # Skip Windows Update (useful when updates already applied or no internet) + [switch] $SkipWindowsUpdate +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Message) + Write-Host "`n=== $Message ===" -ForegroundColor Cyan +} + +# ── Step 0: Windows Update ──────────────────────────────────────────────────── +# NOTE: The Windows Update COM API (Microsoft.Update.Session) raises E_ACCESSDENIED +# when called from a WinRM/network-logon session. We work around this by running +# the actual download+install inside a Scheduled Task (SYSTEM account, full token). +if ($SkipWindowsUpdate) { + Write-Host "[Setup] Skipping Windows Update (SkipWindowsUpdate switch set)." +} +else { + Write-Step "Running Windows Update via Scheduled Task (avoids WinRM token restrictions)" + + # --- worker script that runs as SYSTEM inside the scheduled task --- + $wuWorkerPath = 'C:\CI\wu_worker.ps1' + $wuResultPath = 'C:\CI\wu_result.txt' + $wuRebootPath = 'C:\CI\wu_reboot.txt' + $wuLogPath = 'C:\CI\wu_log.txt' + + $wuWorker = @' +$ErrorActionPreference = 'Stop' +try { + $s = New-Object -ComObject Microsoft.Update.Session + $q = $s.CreateUpdateSearcher() + $found = $q.Search("IsInstalled=0 and Type='Software' and IsHidden=0") + $count = $found.Updates.Count + if ($count -eq 0) { + "0" | Set-Content C:\CI\wu_result.txt + "False" | Set-Content C:\CI\wu_reboot.txt + "No updates needed." | Set-Content C:\CI\wu_log.txt + } else { + "Downloading $count update(s)..." | Set-Content C:\CI\wu_log.txt + $dl = $s.CreateUpdateDownloader() + $dl.Updates = $found.Updates + $dl.Download() | Out-Null + "Installing $count update(s)..." | Add-Content C:\CI\wu_log.txt + $inst = $s.CreateUpdateInstaller() + $inst.Updates = $found.Updates + $r = $inst.Install() + [string]$r.ResultCode | Set-Content C:\CI\wu_result.txt + [string]$r.RebootRequired | Set-Content C:\CI\wu_reboot.txt + "Done. ResultCode=$($r.ResultCode) RebootRequired=$($r.RebootRequired)" | Add-Content C:\CI\wu_log.txt + } +} catch { + "ERROR: $_" | Set-Content C:\CI\wu_log.txt + "99" | Set-Content C:\CI\wu_result.txt + "False" | Set-Content C:\CI\wu_reboot.txt +} +'@ + $wuWorker | Set-Content -Path $wuWorkerPath -Encoding UTF8 + + # Remove any leftover result files from a previous run + Remove-Item $wuResultPath, $wuRebootPath -ErrorAction SilentlyContinue + + # Register scheduled task running as SYSTEM + $taskName = 'CI-WindowsUpdate' + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$wuWorkerPath`"" + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest + Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null + + Write-Host "Scheduled task registered. Starting Windows Update..." + Start-ScheduledTask -TaskName $taskName + + # Poll until the task finishes (result file written = task complete) + $timeout = [datetime]::UtcNow.AddMinutes(60) + $dotCount = 0 + while (-not (Test-Path $wuResultPath)) { + if ([datetime]::UtcNow -gt $timeout) { + throw "Windows Update timed out after 60 minutes." + } + Start-Sleep -Seconds 15 + $dotCount++ + if ($dotCount % 4 -eq 0) { Write-Host " ... still running ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" } + } + + # Read results + $resultCode = [int](Get-Content $wuResultPath -Raw).Trim() + $rebootNeeded = (Get-Content $wuRebootPath -Raw).Trim() -eq 'True' + $log = Get-Content $wuLogPath -Raw -ErrorAction SilentlyContinue + + Write-Host "Windows Update log:" + Write-Host $log + + # Clean up + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-Item $wuWorkerPath, $wuResultPath, $wuRebootPath, $wuLogPath -ErrorAction SilentlyContinue + + if ($resultCode -eq 99) { + throw "Windows Update worker script failed. See log above." + } + if ($resultCode -notin @(0, 2, 3)) { + Write-Warning "Windows Update returned unexpected result code: $resultCode" + } + else { + Write-Host "Windows Update completed (ResultCode=$resultCode)." + } + + if ($rebootNeeded) { + Write-Warning "*** REBOOT REQUIRED to finish installing updates. ***" + Write-Warning " Restart the VM, then re-run this script with: -SkipWindowsUpdate" + exit 3010 + } +} + + +# ── Step 1: WinRM ───────────────────────────────────────────────────────────── +Write-Step "Configuring WinRM" + +Enable-PSRemoting -Force -SkipNetworkProfileCheck 3>$null | Out-Null + +# Allow unencrypted (HTTP/5985) — acceptable for an isolated lab network. +# Migrate to HTTPS/5986 with a self-signed cert for hardened environments. +winrm set winrm/config/service '@{AllowUnencrypted="true"}' | Out-Null +winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null + +# Increase shell memory and timeout limits for long builds +winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="2048"}' | Out-Null +winrm set winrm/config/winrs '@{MaxProcessesPerShell="25"}' | Out-Null + +Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>$null +Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>$null # 2 hours + +# Ensure WinRM starts automatically +Set-Service -Name WinRM -StartupType Automatic 3>$null +Start-Service WinRM 3>$null + +Write-Host "WinRM configured." + +# ── Step 2: Firewall ────────────────────────────────────────────────────────── +Write-Step "Disabling Windows Firewall (all profiles) — isolated lab VM" + +# Disable all firewall profiles entirely. This VM lives on a Host-Only network +# (VMnet11) with no external exposure, so full disable is acceptable and avoids +# any rule-order or profile-classification issues that would block WinRM/ICMP. +Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False +Write-Host "Windows Firewall disabled on all profiles." + +# ── Step 3: Build user account ──────────────────────────────────────────────── +Write-Step "Creating build user account: $BuildUsername" + +$existingUser = Get-LocalUser -Name $BuildUsername -ErrorAction SilentlyContinue +if ($existingUser) { + Write-Host "User '$BuildUsername' already exists — skipping creation." +} +else { + # Use net user instead of New-LocalUser: the LocalAccounts cmdlets have a known + # bug where SecureString deserialized over WinRM causes InvalidPasswordException. + $netResult = & net user $BuildUsername $BuildPassword /add /y 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "Failed to create user '$BuildUsername': $netResult" + } + Write-Host "User '$BuildUsername' created." +} + +# Always ensure password policy and Administrators membership (idempotent) +& net user $BuildUsername /passwordchg:no | Out-Null +Set-LocalUser -Name $BuildUsername -PasswordNeverExpires $true + +$isMember = & net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername +if (-not $isMember) { + & net localgroup Administrators $BuildUsername /add | Out-Null + Write-Host "User '$BuildUsername' added to Administrators." +} +else { + Write-Host "User '$BuildUsername' already in Administrators." +} + +# ── Step 3b: Disable UAC ────────────────────────────────────────────────────── +Write-Step "Disabling UAC (isolated lab VM)" + +# EnableLUA=0 disables UAC entirely — admin processes get full token without prompt. +# LocalAccountTokenFilterPolicy=1 ensures remote admin connections (WinRM) also +# get an elevated token (avoids the filtered-token issue with runProgramInGuest). +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` + -Name EnableLUA -Value 0 -Type DWord -Force +Set-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' ` + -Name LocalAccountTokenFilterPolicy -Value 1 -Type DWord -Force +Write-Host "UAC disabled." + +# ── Step 4: CI working directories ─────────────────────────────────────────── +Write-Step "Creating CI working directories" + +foreach ($dir in @('C:\CI\build', 'C:\CI\output', 'C:\CI\scripts')) { + if (-not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + Write-Host "Created: $dir" + } +} + +# ── Step 4b: Explorer settings ─────────────────────────────────────────────── +Write-Step "Configuring Explorer defaults" + +# Open Explorer to 'This PC' (Devices and Drives) instead of Quick Access. +# LaunchTo=1 = This PC, LaunchTo=2 = Quick Access (default) +$explorerAdvKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' +Set-ItemProperty -Path $explorerAdvKey -Name 'LaunchTo' -Value 1 -Type DWord -Force + +# Also apply to the Default User profile so ci_build and any new user inherits it. +$defaultHive = 'C:\Users\Default\NTUSER.DAT' +$mountName = 'TempDefaultUser' +if (Test-Path $defaultHive) { + reg load "HKU\$mountName" $defaultHive | Out-Null + $defKey = "Registry::HKU\$mountName\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced" + if (-not (Test-Path $defKey)) { New-Item -Path $defKey -Force | Out-Null } + Set-ItemProperty -Path $defKey -Name 'LaunchTo' -Value 1 -Type DWord -Force + [gc]::Collect() + reg unload "HKU\$mountName" | Out-Null + Write-Host "Explorer: 'This PC' set for current user and default profile." +} +else { + Write-Host "Explorer: 'This PC' set for current user (default hive not found)." +} + +# ── Step 5: Disable Windows Defender ──────────────────────────────────────── +Write-Step "Disabling Windows Defender real-time protection" + +# Defender scanning significantly slows compilation and NuGet restore. +# On an isolated CI template VM this trade-off is acceptable. +$defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue +if ($defSvc) { + Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null + Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null + Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null + Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null + # Exclude CI build paths from any remaining scanning + Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null + Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null + Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null + Write-Host "Windows Defender real-time protection disabled." +} +else { + Write-Host "Windows Defender service not found — skipping." +} + +# Disable via Group Policy registry key — survives Defender auto-updates and reboots. +# This is the same key that SCCM/GPO uses in enterprise environments. +$defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' +if (-not (Test-Path $defenderKey)) { + New-Item -Path $defenderKey -Force | Out-Null +} +Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force +# Also disable via the Windows Security Center policy subkey +$rtKey = "$defenderKey\Real-Time Protection" +if (-not (Test-Path $rtKey)) { + New-Item -Path $rtKey -Force | Out-Null +} +Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force +Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force +Write-Host "Defender GPO registry keys written (persistent across updates)." + +# ── Step 6: Install .NET SDK ───────────────────────────────────────────────── +Write-Step "Installing .NET SDK $DotNetSdkVersion" + +$dotnetInstalled = Get-Command dotnet -ErrorAction SilentlyContinue +if ($dotnetInstalled) { + $installedVersion = dotnet --version + Write-Host ".NET SDK already installed: $installedVersion" +} +else { + Write-Host "Downloading dotnet-install.ps1..." + $dotnetInstallScript = "$env:TEMP\dotnet-install.ps1" + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' ` + -OutFile $dotnetInstallScript -UseBasicParsing + + Write-Host "Installing .NET SDK $DotNetSdkVersion (channel)..." + & $dotnetInstallScript ` + -Channel $DotNetSdkVersion ` + -InstallDir 'C:\dotnet' ` + -NoPath + + # Add to system PATH + $currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + if ($currentPath -notlike '*C:\dotnet*') { + [System.Environment]::SetEnvironmentVariable( + 'PATH', + "$currentPath;C:\dotnet", + 'Machine' + ) + } + $env:PATH = $env:PATH + ';C:\dotnet' + + Write-Host ".NET SDK installed: $(dotnet --version)" +} + +# ── Step 7: Install Python ─────────────────────────────────────────────────── +Write-Step "Installing Python $PythonVersion" + +$pythonExe = 'C:\Python\python.exe' +if (Test-Path $pythonExe) { + Write-Host "Python already installed: $(& $pythonExe --version 2>&1)" +} +else { + $pyInstallerUrl = "https://www.python.org/ftp/python/$PythonVersion/python-$PythonVersion-amd64.exe" + $pyInstallerPath = 'C:\CI\python_installer.exe' + Write-Host "Downloading Python $PythonVersion installer..." + Invoke-WebRequest -Uri $pyInstallerUrl -OutFile $pyInstallerPath -UseBasicParsing + + Write-Host "Installing Python $PythonVersion (all users, prepend PATH)..." + $pyProc = Start-Process -FilePath $pyInstallerPath -ArgumentList @( + '/quiet', + 'InstallAllUsers=1', + 'PrependPath=1', + 'Include_test=0', + 'Include_doc=0', + 'TargetDir=C:\Python' + ) -Wait -PassThru + + Remove-Item $pyInstallerPath -ErrorAction SilentlyContinue + + if ($pyProc -and $pyProc.ExitCode -ne 0) { + throw "Python installation failed (exit $($pyProc.ExitCode))." + } + + # Refresh PATH for subsequent steps + $env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + + Write-Host "Python installed: $(python --version 2>&1)" +} + +# ── Step 8: Install Visual Studio Build Tools 2022 ─────────────────────────── +Write-Step "Installing Visual Studio Build Tools 2026" + +$msbuildPath = 'C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools\MSBuild\Current\Bin\MSBuild.exe' +if (Test-Path $msbuildPath) { + Write-Host "VS Build Tools 2026 already installed." +} +else { + $vsInstallerUrl = 'https://aka.ms/vs/stable/vs_buildtools.exe' + $vsInstallerPath = 'C:\CI\vs_BuildTools.exe' + $vsResultPath = 'C:\CI\vs_result.txt' + $vsLogPath = 'C:\CI\vs_log.txt' + + Write-Host "Downloading VS Build Tools installer..." + Remove-Item $vsInstallerPath -ErrorAction SilentlyContinue # remove any stale/corrupt copy + Invoke-WebRequest -Uri $vsInstallerUrl -OutFile $vsInstallerPath -UseBasicParsing + + # Remove Zone.Identifier ADS — files downloaded from internet are marked Zone 3 (Internet). + # SYSTEM account cannot execute them without this unblock step. + Unblock-File -Path $vsInstallerPath -ErrorAction SilentlyContinue + + # Verify the download produced a valid Windows PE file (MZ header) + $mzBytes = [System.IO.File]::ReadAllBytes($vsInstallerPath) | Select-Object -First 2 + if (-not ($mzBytes[0] -eq 0x4D -and $mzBytes[1] -eq 0x5A)) { + $fileSize = (Get-Item $vsInstallerPath).Length + throw "VS installer download appears invalid (size=$fileSize, not an EXE). Check the URL: $vsInstallerUrl" + } + Write-Host "Installer verified OK ($((Get-Item $vsInstallerPath).Length) bytes)." + + # VS installer fails under WinRM (network logon token). Run via Scheduled Task as SYSTEM. + $vsWorkerPath = 'C:\CI\vs_worker.ps1' + $vsWorker = @" +# SYSTEM account may have no TEMP set — use C:\Windows\Temp +`$env:TEMP = 'C:\Windows\Temp' +`$env:TMP = 'C:\Windows\Temp' + +# Clean any corrupt VS installer cache left by previous failed attempts +Remove-Item 'C:\ProgramData\Microsoft\VisualStudio\Packages' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item 'C:\Windows\Temp\vs*' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item 'C:\Windows\Temp\dd_*' -Force -ErrorAction SilentlyContinue + +`$result = try { + `$proc = Start-Process -FilePath '$vsInstallerPath' -ArgumentList @( + '--quiet','--wait','--norestart','--nocache', + '--installPath "C:\Program Files (x86)\Microsoft Visual Studio\2026\BuildTools"', + '--add','Microsoft.VisualStudio.Workload.VCTools', + '--includeRecommended', + '--add','Microsoft.VisualStudio.Workload.MSBuildTools', + '--add','Microsoft.VisualStudio.Workload.ManagedDesktopBuildTools', + '--add','Microsoft.VisualStudio.Component.NuGet.BuildTools', + '--add','Microsoft.Net.Component.4.8.SDK', + '--add','Microsoft.Net.Component.4.7.2.TargetingPack' + ) -Wait -PassThru + if (`$proc) { [string]`$proc.ExitCode } else { '99' } +} catch { + "99: `$_" +} +if (-not `$result) { `$result = '99' } +`$result | Set-Content '$vsResultPath' -Encoding UTF8 +"@ + $vsWorker | Set-Content -Path $vsWorkerPath -Encoding UTF8 + + Remove-Item $vsResultPath -ErrorAction SilentlyContinue + + $taskName = 'CI-VSBuildTools' + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` + -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$vsWorkerPath`"" + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -RunLevel Highest + Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null + + Write-Host "Installing VS Build Tools via Scheduled Task (this may take 15-30 minutes)..." + Start-ScheduledTask -TaskName $taskName + + $timeout = [datetime]::UtcNow.AddMinutes(60) + $dots = 0 + while (-not (Test-Path $vsResultPath)) { + if ([datetime]::UtcNow -gt $timeout) { throw "VS Build Tools installation timed out." } + # If task already finished without writing the result file, the worker itself crashed + $taskInfo = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($taskInfo -and $taskInfo.State -eq 'Ready') { + $lastResult = (Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction SilentlyContinue).LastTaskResult + throw "VS worker task exited prematurely (LastTaskResult=0x$('{0:X}' -f $lastResult)). Check C:\CI\vs_worker.ps1 for syntax errors." + } + Start-Sleep -Seconds 20 + $dots++ + if ($dots % 3 -eq 0) { Write-Host " ... still installing ($(([datetime]::UtcNow - ($timeout.AddMinutes(-60))).Minutes) min elapsed)" } + } + + $rawResult = Get-Content $vsResultPath -Raw -ErrorAction SilentlyContinue + $rawResult = if ($rawResult) { $rawResult.Trim() } else { '99' } + # Result file may contain "0", "3010", or "99: " + $vsExit = if ($rawResult -match '^\d+$') { [int]$rawResult } else { 99 } + $vsMsg = if ($vsExit -eq 99) { $rawResult } else { '' } + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-Item $vsWorkerPath, $vsResultPath, $vsInstallerPath -ErrorAction SilentlyContinue + + if ($vsExit -notin @(0, 3010)) { + throw "VS Build Tools installation failed (exit $vsExit). $vsMsg" + } + Write-Host "VS Build Tools 2026 installed (exit code $vsExit)." +} + +# Add MSBuild to system PATH +$msbuildDir = Split-Path $msbuildPath -Parent +$currentPath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') +if ($currentPath -notlike "*$msbuildDir*") { + [System.Environment]::SetEnvironmentVariable( + 'PATH', + "$currentPath;$msbuildDir", + 'Machine' + ) + Write-Host "MSBuild added to system PATH." +} + +# ── Step 9: Verify toolchain ─────────────────────────────────────────────── +Write-Step "Verifying toolchain" + +$env:PATH = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('PATH', 'User') + +$allOk = $true + +# Helper: resolve a binary — tries hardcoded path first, then Get-Command (PATH) +function Resolve-Tool { + param([string]$HardcodedPath, [string]$CommandName) + if ($HardcodedPath -and (Test-Path $HardcodedPath -PathType Leaf)) { + return $HardcodedPath + } + $found = Get-Command $CommandName -ErrorAction SilentlyContinue + if ($found) { return $found.Source } + return $null +} + +# dotnet +$dotnetBin = Resolve-Tool '' 'dotnet' +if ($dotnetBin) { + $v = & $dotnetBin --version 2>&1 | Select-Object -First 1 + Write-Host " [OK] dotnet: $v ($dotnetBin)" +} else { + Write-Warning " [FAIL] dotnet not found" + $allOk = $false +} + +# python +$pythonBin = Resolve-Tool 'C:\Python\python.exe' 'python' +if ($pythonBin) { + $v = & $pythonBin --version 2>&1 | Select-Object -First 1 + Write-Host " [OK] python: $v ($pythonBin)" +} else { + Write-Warning " [FAIL] python not found" + $allOk = $false +} + +# msbuild +$msbuildBin = Resolve-Tool $msbuildPath 'msbuild' +if ($msbuildBin) { + $v = & $msbuildBin -version 2>&1 | Select-Object -First 1 + Write-Host " [OK] msbuild: $v ($msbuildBin)" +} else { + Write-Warning " [FAIL] msbuild not found at '$msbuildPath' and not in PATH" + $allOk = $false +} + +if (-not $allOk) { + Write-Warning "Some tools failed verification. Review above and rerun if needed." +} + +# ── Cleanup ─────────────────────────────────────────────────────────────────── +Write-Step "Cleaning up disk" + +# 1. Windows Disk Cleanup (cleanmgr) — run with /sagerun:1 using preset flags +# First register the flags via /sageset:1 in the registry (silent, no UI) +$cleanKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches' +$sageset = 1 +$cleanCategories = @( + 'Active Setup Temp Folders', + 'BranchCache', + 'Downloaded Program Files', + 'Internet Cache Files', + 'Memory Dump Files', + 'Old ChkDsk Files', + 'Previous Installations', + 'Recycle Bin', + 'Service Pack Cleanup', + 'Setup Log Files', + 'System error memory dump files', + 'System error minidump files', + 'Temporary Files', + 'Temporary Setup Files', + 'Thumbnail Cache', + 'Update Cleanup', + 'Upgrade Discarded Files', + 'Windows Defender', + 'Windows Error Reporting Archive Files', + 'Windows Error Reporting Files', + 'Windows Error Reporting Queue Files', + 'Windows Error Reporting Temp Files', + 'Windows ESD installation files', + 'Windows Upgrade Log Files' +) +foreach ($cat in $cleanCategories) { + $keyPath = "$cleanKey\$cat" + if (Test-Path $keyPath) { + Set-ItemProperty -Path $keyPath -Name "StateFlags$('{0:D4}' -f $sageset)" -Value 2 -Type DWord -Force -ErrorAction SilentlyContinue + } +} +Write-Host "Running cleanmgr /sagerun:$sageset (may take a few minutes)..." +$proc = Start-Process -FilePath 'cleanmgr.exe' -ArgumentList "/sagerun:$sageset" -Wait -PassThru +Write-Host "cleanmgr exited (code $($proc.ExitCode))." + +# 2. Clear Windows Update cache +Write-Host "Stopping Windows Update service..." +Stop-Service -Name wuauserv -Force -ErrorAction SilentlyContinue +Remove-Item 'C:\Windows\SoftwareDistribution\Download\*' -Recurse -Force -ErrorAction SilentlyContinue +Start-Service -Name wuauserv -ErrorAction SilentlyContinue +Write-Host "Windows Update download cache cleared." + +# 3. Clear CBS / component store logs +Remove-Item 'C:\Windows\Logs\CBS\*' -Force -ErrorAction SilentlyContinue + +# 4. Clear TEMP folders +Remove-Item 'C:\Windows\Temp\*' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "$env:TEMP\*" -Recurse -Force -ErrorAction SilentlyContinue + +# 5. Clear Prefetch +Remove-Item 'C:\Windows\Prefetch\*' -Force -ErrorAction SilentlyContinue + +# 6. Run DISM component store cleanup (removes superseded update components) +Write-Host "Running DISM component store cleanup (StartComponentCleanup)..." +$dism = Start-Process -FilePath 'dism.exe' ` + -ArgumentList '/Online','/Cleanup-Image','/StartComponentCleanup','/ResetBase' ` + -Wait -PassThru +Write-Host "DISM exited (code $($dism.ExitCode))." + +Write-Host "Disk cleanup complete." + +# ── Done ────────────────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "============================================================" -ForegroundColor Green +Write-Host " Template VM setup complete!" -ForegroundColor Green +Write-Host "============================================================" -ForegroundColor Green +Write-Host "" +Write-Host " MANDATORY NEXT STEPS:" +Write-Host "" +Write-Host " 1. SWITCH NIC TO HOST-ONLY (critical before snapshot!):" +Write-Host " VMware Workstation -> VM -> Settings -> Network Adapter" +Write-Host " Change from: VMnet8 (NAT)" +Write-Host " Change to: Custom: VMnet11" +Write-Host "" +Write-Host " 2. Shut down this VM: Start -> Shut down" +Write-Host "" +Write-Host " 3. Take snapshot in VMware Workstation:" +Write-Host " VM -> Snapshot -> Take Snapshot" +Write-Host " Name it EXACTLY: BaseClean" +Write-Host "" +Write-Host " 4. Leave VM powered off permanently." +Write-Host "" +Write-Host " 5. On the HOST - store credentials in Windows Credential Manager:" +Write-Host " New-StoredCredential -Target 'BuildVMGuest' -UserName '$BuildUsername' ``" +Write-Host " -Password '$BuildPassword' -Persist LocalMachine" +Write-Host " (Run in an elevated PowerShell with CredentialManager module)" +Write-Host ""