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