chore: initial commit local CI/CD system (e2e verified, production-ready)
Sistema CI locale basato su VMware Workstation + Gitea Actions + act_runner. Testato e2e con nsis-plugin-nsinnounp (MSBuild + Python, 4 configurazioni parallele). - scripts/: Invoke-CIJob, Invoke-RemoteBuild, New/Remove-BuildVM, Wait-VMReady, Get-BuildArtifacts - runner/: act_runner config (windows-build label, capacity 4) - gitea/workflows/: build-nsis.yml (template per progetti MSBuild/Python) - template/: script di provisioning template VM (VS BuildTools 2026, .NET SDK 10, Python 3.13) - docs/: ARCHITECTURE, CI-FLOW, OPTIMIZATION, BEST-PRACTICES, Setup-GiteaSSH Verificato: e2e-009 SUCCESS in 02:09, cleanup automatico VM confermato.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Copies build artifacts from an ephemeral VM to the host artifact directory.
|
||||
|
||||
.DESCRIPTION
|
||||
Opens a WinRM session to the build VM and uses Copy-Item -FromSession
|
||||
to transfer the artifact archive and optional log files.
|
||||
Validates that at least one file was successfully transferred.
|
||||
Throws on failure so the caller's try/finally can clean up the VM.
|
||||
|
||||
.PARAMETER IPAddress
|
||||
IP of the build VM.
|
||||
|
||||
.PARAMETER Credential
|
||||
PSCredential for the guest build user.
|
||||
|
||||
.PARAMETER GuestArtifactPath
|
||||
Full path to the artifact file or directory inside the VM.
|
||||
Example: C:\CI\output\artifacts.zip
|
||||
|
||||
.PARAMETER HostArtifactDir
|
||||
Directory on the host where artifacts will be stored.
|
||||
The directory is created if it does not exist.
|
||||
Example: F:\CI\Artifacts\run-42
|
||||
|
||||
.PARAMETER IncludeLogs
|
||||
If set, also copies *.log files from the guest work directory
|
||||
(C:\CI\build\*.log) alongside the artifact archive.
|
||||
|
||||
.EXAMPLE
|
||||
$cred = Get-Credential
|
||||
.\Get-BuildArtifacts.ps1 `
|
||||
-IPAddress "192.168.11.101" `
|
||||
-Credential $cred `
|
||||
-GuestArtifactPath "C:\CI\output\artifacts.zip" `
|
||||
-HostArtifactDir "F:\CI\Artifacts\run-42"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[System.Management.Automation.PSCredential] $Credential,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $GuestArtifactPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $HostArtifactDir,
|
||||
|
||||
[switch] $IncludeLogs
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Ensure destination exists on host
|
||||
if (-not (Test-Path $HostArtifactDir)) {
|
||||
New-Item -ItemType Directory -Path $HostArtifactDir -Force | Out-Null
|
||||
Write-Host "[Get-BuildArtifacts] Created host artifact directory: $HostArtifactDir"
|
||||
}
|
||||
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..."
|
||||
$session = New-PSSession `
|
||||
-ComputerName $IPAddress `
|
||||
-Credential $Credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-ErrorAction Stop
|
||||
|
||||
try {
|
||||
# ── Verify artifact exists in guest ──────────────────────────────────
|
||||
$guestExists = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($path)
|
||||
Test-Path $path
|
||||
} -ArgumentList $GuestArtifactPath
|
||||
|
||||
if (-not $guestExists) {
|
||||
throw "Artifact not found in guest at: $GuestArtifactPath"
|
||||
}
|
||||
|
||||
# ── Copy main artifact ────────────────────────────────────────────────
|
||||
$artifactFileName = Split-Path $GuestArtifactPath -Leaf
|
||||
$hostDestPath = Join-Path $HostArtifactDir $artifactFileName
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Copying $artifactFileName from guest..."
|
||||
Copy-Item -Path $GuestArtifactPath -Destination $hostDestPath -FromSession $session -Force
|
||||
|
||||
# ── Copy logs if requested ────────────────────────────────────────────
|
||||
if ($IncludeLogs) {
|
||||
$logFiles = Invoke-Command -Session $session -ScriptBlock {
|
||||
Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty FullName
|
||||
}
|
||||
|
||||
foreach ($log in $logFiles) {
|
||||
$logDest = Join-Path $HostArtifactDir (Split-Path $log -Leaf)
|
||||
Write-Host "[Get-BuildArtifacts] Copying log: $(Split-Path $log -Leaf)"
|
||||
Copy-Item -Path $log -Destination $logDest -FromSession $session -Force
|
||||
}
|
||||
}
|
||||
|
||||
# ── Validate at least the main artifact landed ────────────────────────
|
||||
if (-not (Test-Path $hostDestPath)) {
|
||||
throw "Artifact was not found at expected host path after copy: $hostDestPath"
|
||||
}
|
||||
|
||||
$sizeKB = [math]::Round((Get-Item $hostDestPath).Length / 1KB, 1)
|
||||
if ($sizeKB -eq 0) {
|
||||
throw "Artifact file exists but is empty: $hostDestPath"
|
||||
}
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
|
||||
return $hostDestPath
|
||||
}
|
||||
finally {
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Main CI orchestrator: creates an ephemeral VM, runs a build, collects
|
||||
artifacts, and destroys the VM — in a guaranteed try/finally pattern.
|
||||
|
||||
.DESCRIPTION
|
||||
This script is called by act_runner as the primary build step.
|
||||
It coordinates all sub-scripts:
|
||||
1. New-BuildVM.ps1 — linked clone from template
|
||||
2. vmrun start — start the clone
|
||||
3. Wait-VMReady.ps1 — poll until WinRM is ready
|
||||
4. Invoke-RemoteBuild.ps1 — execute build inside VM
|
||||
5. Get-BuildArtifacts.ps1 — copy artifacts to host
|
||||
6. Remove-BuildVM.ps1 — destroy VM (always runs via finally)
|
||||
|
||||
Exit codes:
|
||||
0 = success
|
||||
1 = build or orchestration failure (VM destroyed, artifacts may be partial)
|
||||
|
||||
.PARAMETER JobId
|
||||
Unique job identifier (use ${{ github.run_id }} from Gitea Actions).
|
||||
|
||||
.PARAMETER RepoUrl
|
||||
Git URL of the repository to build (reachable from the build VM).
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch to build.
|
||||
|
||||
.PARAMETER Commit
|
||||
Optional specific commit SHA to check out.
|
||||
|
||||
.PARAMETER Configuration
|
||||
Build configuration. Default: Release
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Full path to the template VM .vmx file.
|
||||
Can also be set via env var GITEA_CI_TEMPLATE_PATH.
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Template snapshot to clone from. Default: BaseClean
|
||||
|
||||
.PARAMETER CloneBaseDir
|
||||
Base directory for clone VMs.
|
||||
Can also be set via env var GITEA_CI_CLONE_BASE_DIR.
|
||||
Default: F:\CI\BuildVMs
|
||||
|
||||
.PARAMETER ArtifactBaseDir
|
||||
Host directory where artifacts are stored per job.
|
||||
Default: F:\CI\Artifacts
|
||||
|
||||
.PARAMETER VMIPAddress
|
||||
IP address to assign/expect for the build VM.
|
||||
Must be unique per concurrent build.
|
||||
Can be passed explicitly or derived from a registry/file-based IP allocator.
|
||||
|
||||
.PARAMETER GuestCredentialTarget
|
||||
Target name in Windows Credential Manager for guest credentials.
|
||||
Default: BuildVMGuest
|
||||
|
||||
.PARAMETER VmrunPath
|
||||
Path to vmrun.exe.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||
|
||||
.EXAMPLE
|
||||
# Called from Gitea Actions workflow:
|
||||
.\scripts\Invoke-CIJob.ps1 `
|
||||
-JobId "${{ github.run_id }}" `
|
||||
-RepoUrl "http://gitea.local/org/myrepo.git" `
|
||||
-Branch "${{ github.ref_name }}" `
|
||||
-Commit "${{ github.sha }}" `
|
||||
-VMIPAddress "192.168.11.101"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $JobId,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $RepoUrl,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $Branch,
|
||||
|
||||
[string] $Commit = '',
|
||||
|
||||
[string] $Configuration = 'Release',
|
||||
|
||||
# Pass -Submodules to clone with --recurse-submodules
|
||||
[switch] $Submodules,
|
||||
|
||||
# Custom build command to run inside the VM (empty = dotnet build)
|
||||
[string] $BuildCommand = '',
|
||||
|
||||
# Artifact source pattern relative to GuestWorkDir (used with BuildCommand)
|
||||
[string] $GuestArtifactSource = 'dist',
|
||||
|
||||
[string] $TemplatePath = $env:GITEA_CI_TEMPLATE_PATH,
|
||||
|
||||
[string] $SnapshotName = 'BaseClean',
|
||||
|
||||
[string] $CloneBaseDir = $(if ($env:GITEA_CI_CLONE_BASE_DIR) { $env:GITEA_CI_CLONE_BASE_DIR } else { 'F:\CI\BuildVMs' }),
|
||||
|
||||
[string] $ArtifactBaseDir = 'F:\CI\Artifacts',
|
||||
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[string] $VMIPAddress = '', # if empty, auto-detected via vmrun getGuestIPAddress
|
||||
|
||||
[string] $GuestCredentialTarget = 'BuildVMGuest',
|
||||
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Resolve script directory for dot-sourcing sibling scripts ─────────────────
|
||||
$scriptDir = $PSScriptRoot
|
||||
|
||||
# ── Validate required config ──────────────────────────────────────────────────
|
||||
if ([string]::IsNullOrWhiteSpace($TemplatePath)) {
|
||||
Write-Error "TemplatePath is required. Set -TemplatePath or env:GITEA_CI_TEMPLATE_PATH."
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path $TemplatePath -PathType Leaf)) {
|
||||
Write-Error "Template VMX not found: $TemplatePath"
|
||||
exit 1
|
||||
}
|
||||
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
|
||||
Write-Error "vmrun.exe not found: $VmrunPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Load guest credentials from Windows Credential Manager ───────────────────
|
||||
# Requires the CredentialManager module: Install-Module CredentialManager
|
||||
# In a lab setup you can fall back to prompting, but never hardcode credentials.
|
||||
$credential = $null
|
||||
try {
|
||||
$credential = Get-StoredCredential -Target $GuestCredentialTarget -ErrorAction Stop
|
||||
Write-Host "[Invoke-CIJob] Loaded guest credentials from Credential Manager ($GuestCredentialTarget)."
|
||||
}
|
||||
catch {
|
||||
Write-Warning "[Invoke-CIJob] Could not load credentials from Credential Manager ($GuestCredentialTarget)."
|
||||
Write-Warning " Using interactive prompt as fallback. Store credentials with:"
|
||||
Write-Warning " cmdkey /generic:$GuestCredentialTarget /user:DOMAIN\user /pass:password"
|
||||
$credential = Get-Credential -Message "Enter guest VM credentials for job $JobId"
|
||||
}
|
||||
|
||||
# ── Setup paths ───────────────────────────────────────────────────────────────
|
||||
$jobArtifactDir = Join-Path $ArtifactBaseDir $JobId
|
||||
$hostCloneDir = Join-Path $env:TEMP "ci-src-$JobId"
|
||||
$cloneVmxPath = $null
|
||||
$startTime = Get-Date
|
||||
|
||||
Write-Host "============================================================"
|
||||
Write-Host "[Invoke-CIJob] Job : $JobId"
|
||||
Write-Host "[Invoke-CIJob] Repository : $RepoUrl"
|
||||
Write-Host "[Invoke-CIJob] Branch : $Branch"
|
||||
if ($Commit) { Write-Host "[Invoke-CIJob] Commit : $Commit" }
|
||||
Write-Host "[Invoke-CIJob] Build VM IP : $VMIPAddress"
|
||||
Write-Host "[Invoke-CIJob] Template : $TemplatePath"
|
||||
Write-Host "[Invoke-CIJob] Artifacts : $jobArtifactDir"
|
||||
Write-Host "[Invoke-CIJob] Started : $startTime"
|
||||
Write-Host "============================================================"
|
||||
|
||||
$exitCode = 0
|
||||
|
||||
try {
|
||||
# ── Phase 1: Clone repo on HOST ───────────────────────────────────────
|
||||
# The build VM has no internet/LAN access (Host-Only network).
|
||||
# Source is cloned here on the host, then copied into the VM via WinRM.
|
||||
Write-Host "`n[Phase 1/6] Cloning repository on host..."
|
||||
if (Test-Path $hostCloneDir) { Remove-Item $hostCloneDir -Recurse -Force }
|
||||
$gitArgs = @('clone', '--depth', '1', '--branch', $Branch)
|
||||
if ($Submodules) { $gitArgs += '--recurse-submodules' }
|
||||
$gitArgs += @($RepoUrl, $hostCloneDir)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$gitOutput = & git @gitArgs 2>&1 | ForEach-Object { "$_" }
|
||||
$gitExit = $LASTEXITCODE
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($gitExit -ne 0) {
|
||||
throw "git clone failed (exit $gitExit):`n$($gitOutput -join "`n")"
|
||||
}
|
||||
if ($Commit -ne '') {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$gitOutput = & git -C $hostCloneDir checkout $Commit 2>&1 | ForEach-Object { "$_" }
|
||||
$gitExit = $LASTEXITCODE
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($gitExit -ne 0) {
|
||||
throw "git checkout $Commit failed (exit $gitExit):`n$($gitOutput -join "`n")"
|
||||
}
|
||||
}
|
||||
Write-Host "[Invoke-CIJob] Source cloned to: $hostCloneDir"
|
||||
|
||||
# ── Phase 2: Clone VM ─────────────────────────────────────────────────
|
||||
Write-Host "`n[Phase 2/6] Cloning VM..."
|
||||
$cloneVmxPath = & "$scriptDir\New-BuildVM.ps1" `
|
||||
-TemplatePath $TemplatePath `
|
||||
-SnapshotName $SnapshotName `
|
||||
-CloneBaseDir $CloneBaseDir `
|
||||
-JobId $JobId `
|
||||
-VmrunPath $VmrunPath
|
||||
|
||||
# ── Phase 3: Start VM ─────────────────────────────────────────────────
|
||||
Write-Host "`n[Phase 3/6] Starting VM..."
|
||||
$startOutput = & $VmrunPath -T ws start $cloneVmxPath nogui 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "vmrun start failed (exit $LASTEXITCODE): $startOutput"
|
||||
}
|
||||
Write-Host "[Invoke-CIJob] VM started."
|
||||
|
||||
# ── Phase 3b: Resolve VM IP if not provided ─────────────────────────
|
||||
if ([string]::IsNullOrWhiteSpace($VMIPAddress)) {
|
||||
Write-Host "[Invoke-CIJob] Auto-detecting VM IP via vmrun getGuestIPAddress..."
|
||||
$detectedIP = & $VmrunPath getGuestIPAddress $cloneVmxPath -wait 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or $detectedIP -notmatch '^(\d{1,3}\.){3}\d{1,3}$') {
|
||||
throw "Could not detect VM IP address: $detectedIP"
|
||||
}
|
||||
$VMIPAddress = $detectedIP.Trim()
|
||||
Write-Host "[Invoke-CIJob] Detected VM IP: $VMIPAddress"
|
||||
}
|
||||
|
||||
# ── Phase 4: Wait for readiness ───────────────────────────────────────
|
||||
Write-Host "`n[Phase 4/6] Waiting for VM readiness..."
|
||||
& "$scriptDir\Wait-VMReady.ps1" `
|
||||
-VMPath $cloneVmxPath `
|
||||
-IPAddress $VMIPAddress `
|
||||
-VmrunPath $VmrunPath
|
||||
|
||||
# ── Phase 5: Remote build (copy source + build inside VM) ─────────────
|
||||
Write-Host "`n[Phase 5/6] Invoking remote build..."
|
||||
$remoteBuildParams = @{
|
||||
IPAddress = $VMIPAddress
|
||||
Credential = $credential
|
||||
HostSourceDir = $hostCloneDir
|
||||
Configuration = $Configuration
|
||||
}
|
||||
if ($BuildCommand -ne '') { $remoteBuildParams['BuildCommand'] = $BuildCommand }
|
||||
if ($GuestArtifactSource -ne '') { $remoteBuildParams['GuestArtifactSource'] = $GuestArtifactSource }
|
||||
& "$scriptDir\Invoke-RemoteBuild.ps1" @remoteBuildParams
|
||||
|
||||
# ── Phase 6: Collect artifacts ────────────────────────────────────────
|
||||
Write-Host "`n[Phase 6/6] Collecting artifacts..."
|
||||
& "$scriptDir\Get-BuildArtifacts.ps1" `
|
||||
-IPAddress $VMIPAddress `
|
||||
-Credential $credential `
|
||||
-GuestArtifactPath 'C:\CI\output\artifacts.zip' `
|
||||
-HostArtifactDir $jobArtifactDir `
|
||||
-IncludeLogs
|
||||
|
||||
$elapsed = (Get-Date) - $startTime
|
||||
Write-Host "`n============================================================"
|
||||
Write-Host "[Invoke-CIJob] SUCCESS — Job $JobId completed in $($elapsed.ToString('mm\:ss'))"
|
||||
Write-Host "[Invoke-CIJob] Artifacts: $jobArtifactDir"
|
||||
Write-Host "============================================================"
|
||||
}
|
||||
catch {
|
||||
$exitCode = 1
|
||||
$elapsed = (Get-Date) - $startTime
|
||||
Write-Host "`n============================================================"
|
||||
Write-Host "[Invoke-CIJob] FAILURE — Job $JobId failed after $($elapsed.ToString('mm\:ss'))"
|
||||
Write-Host "Error: $_"
|
||||
Write-Host "============================================================"
|
||||
}
|
||||
finally {
|
||||
# ── Always destroy the VM ─────────────────────────────────────────────
|
||||
if ($null -ne $cloneVmxPath -and (Test-Path $cloneVmxPath -PathType Leaf)) {
|
||||
Write-Host "`n[Cleanup] Destroying ephemeral VM..."
|
||||
& "$scriptDir\Remove-BuildVM.ps1" -VMPath $cloneVmxPath -VmrunPath $VmrunPath
|
||||
}
|
||||
else {
|
||||
Write-Host "[Cleanup] No VM to destroy (clone was not created or already gone)."
|
||||
}
|
||||
# ── Always clean up host-side git clone ──────────────────────────────
|
||||
if (Test-Path $hostCloneDir) {
|
||||
Write-Host "[Cleanup] Removing host source clone ($hostCloneDir)..."
|
||||
Remove-Item $hostCloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
exit $exitCode
|
||||
@@ -0,0 +1,202 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Copies pre-cloned source from host into a build VM and runs dotnet build.
|
||||
|
||||
.DESCRIPTION
|
||||
The caller (Invoke-CIJob.ps1) is responsible for cloning the repository
|
||||
on the HOST before calling this script. This script then:
|
||||
1. Compresses the local source tree on the host, copies the archive into
|
||||
the VM via WinRM (single file = much faster than recursive Copy-Item),
|
||||
then expands it inside the VM.
|
||||
2. Runs dotnet restore and dotnet build inside the VM
|
||||
3. Packages the output as C:\CI\output\artifacts.zip inside the VM
|
||||
|
||||
The build VM does NOT need internet access or git. All network I/O happens
|
||||
on the host before this script is called.
|
||||
|
||||
.PARAMETER IPAddress
|
||||
IP of the build VM (e.g. 192.168.11.101).
|
||||
|
||||
.PARAMETER Credential
|
||||
PSCredential for the build user inside the VM.
|
||||
Retrieve from Windows Credential Manager in production:
|
||||
$cred = Get-StoredCredential -Target 'BuildVMGuest'
|
||||
|
||||
.PARAMETER HostSourceDir
|
||||
Full path on the HOST to the already-cloned repository directory.
|
||||
This directory is copied into the VM as the build working directory.
|
||||
Example: C:\Users\user\AppData\Local\Temp\ci-src-run-42
|
||||
|
||||
.PARAMETER Configuration
|
||||
MSBuild/dotnet build configuration. Default: Release
|
||||
|
||||
.PARAMETER GuestWorkDir
|
||||
Working directory inside the VM where source will be placed.
|
||||
Default: C:\CI\build
|
||||
|
||||
.PARAMETER GuestArtifactZip
|
||||
Full path inside the VM where the artifact archive will be written.
|
||||
Default: C:\CI\output\artifacts.zip
|
||||
|
||||
.EXAMPLE
|
||||
$cred = Get-Credential
|
||||
.\Invoke-RemoteBuild.ps1 `
|
||||
-IPAddress "192.168.11.101" `
|
||||
-Credential $cred `
|
||||
-HostSourceDir "C:\Temp\ci-src-run-42"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[System.Management.Automation.PSCredential] $Credential,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateScript({ Test-Path $_ -PathType Container })]
|
||||
[string] $HostSourceDir,
|
||||
|
||||
[string] $Configuration = 'Release',
|
||||
|
||||
# Custom build command to run inside the VM (working dir = GuestWorkDir).
|
||||
# When empty, defaults to: dotnet restore + dotnet build --configuration $Configuration
|
||||
# Example: 'python build_plugin.py --final'
|
||||
[string] $BuildCommand = '',
|
||||
|
||||
# Glob pattern (relative to GuestWorkDir) of files to include in the artifact zip.
|
||||
# Used only when BuildCommand is set. Default: collect everything under 'dist'.
|
||||
# Example: 'dist\**' or 'Contrib\nsInnoUnp\Build\**'
|
||||
[string] $GuestArtifactSource = 'dist',
|
||||
|
||||
[string] $GuestWorkDir = 'C:\CI\build',
|
||||
|
||||
[string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# ── Session options: skip SSL certificate checks for self-signed (lab use) ──
|
||||
$sessionOptions = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Connecting to VM at $IPAddress..."
|
||||
|
||||
$session = New-PSSession `
|
||||
-ComputerName $IPAddress `
|
||||
-Credential $Credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-ErrorAction Stop
|
||||
|
||||
try {
|
||||
# ── Ensure working directories exist on guest ────────────────────────
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir, $outputDir)
|
||||
foreach ($dir in @($workDir, (Split-Path $outputDir -Parent))) {
|
||||
if (-not (Test-Path $dir)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
}
|
||||
# Clean previous build if present
|
||||
if (Test-Path $workDir) {
|
||||
Remove-Item $workDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
|
||||
} -ArgumentList $GuestWorkDir, $GuestArtifactZip
|
||||
|
||||
# ── Copy source tree: compress on host, transfer zip, expand in guest ──
|
||||
# Single-file transfer over WinRM is far faster than recursive Copy-Item.
|
||||
$hostZip = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.zip'
|
||||
try {
|
||||
Write-Host "[Invoke-RemoteBuild] Compressing source on host..."
|
||||
Compress-Archive -Path "$HostSourceDir\*" -DestinationPath $hostZip -CompressionLevel Fastest -Force
|
||||
$zipSize = [math]::Round((Get-Item $hostZip).Length / 1MB, 1)
|
||||
Write-Host "[Invoke-RemoteBuild] Transferring archive to guest ($zipSize MB)..."
|
||||
$guestZip = 'C:\CI\src-transfer.zip'
|
||||
Copy-Item -Path $hostZip -Destination $guestZip -ToSession $session -Force
|
||||
Write-Host "[Invoke-RemoteBuild] Expanding archive in guest..."
|
||||
Invoke-Command -Session $session -ScriptBlock {
|
||||
param($zip, $dest)
|
||||
Expand-Archive -Path $zip -DestinationPath $dest -Force
|
||||
Remove-Item $zip -Force
|
||||
} -ArgumentList $guestZip, $GuestWorkDir
|
||||
Write-Host "[Invoke-RemoteBuild] Source transfer complete."
|
||||
}
|
||||
finally {
|
||||
Remove-Item $hostZip -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────
|
||||
if ($BuildCommand -ne '') {
|
||||
# ── Custom build command (e.g. python, msbuild, cmake…) ──────────
|
||||
Write-Host "[Invoke-RemoteBuild] Running build command: $BuildCommand"
|
||||
$buildResult = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir, $cmd, $artifactZip, $artifactSrc)
|
||||
Set-Location $workDir
|
||||
$output = Invoke-Expression "$cmd 2>&1" | ForEach-Object { "$_" }
|
||||
$buildExit = $LASTEXITCODE
|
||||
|
||||
if ($buildExit -eq 0) {
|
||||
$archiveDir = Split-Path $artifactZip -Parent
|
||||
if (-not (Test-Path $archiveDir)) {
|
||||
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
|
||||
}
|
||||
$srcPath = Join-Path $workDir $artifactSrc
|
||||
Compress-Archive -Path $srcPath -DestinationPath $artifactZip -Force
|
||||
}
|
||||
|
||||
return @{ ExitCode = $buildExit; Output = ($output -join "`n") }
|
||||
} -ArgumentList $GuestWorkDir, $BuildCommand, $GuestArtifactZip, $GuestArtifactSource
|
||||
|
||||
Write-Host $buildResult.Output
|
||||
if ($buildResult.ExitCode -ne 0) {
|
||||
throw "Build command failed (exit $($buildResult.ExitCode))"
|
||||
}
|
||||
}
|
||||
else {
|
||||
# ── Default: dotnet restore + dotnet build ────────────────────────
|
||||
Write-Host "[Invoke-RemoteBuild] Running dotnet restore..."
|
||||
$restoreResult = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir)
|
||||
Set-Location $workDir
|
||||
$output = dotnet restore 2>&1
|
||||
return @{ ExitCode = $LASTEXITCODE; Output = ($output -join "`n") }
|
||||
} -ArgumentList $GuestWorkDir
|
||||
|
||||
Write-Host $restoreResult.Output
|
||||
if ($restoreResult.ExitCode -ne 0) {
|
||||
throw "dotnet restore failed (exit $($restoreResult.ExitCode))"
|
||||
}
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Running dotnet build (configuration: $Configuration)..."
|
||||
$buildResult = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($workDir, $config, $artifactZip)
|
||||
Set-Location $workDir
|
||||
$outputDir = Join-Path $workDir 'bin\CIOutput'
|
||||
$output = dotnet build --configuration $config --output $outputDir 2>&1
|
||||
$buildExit = $LASTEXITCODE
|
||||
|
||||
if ($buildExit -eq 0) {
|
||||
$archiveDir = Split-Path $artifactZip -Parent
|
||||
if (-not (Test-Path $archiveDir)) {
|
||||
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
|
||||
}
|
||||
Compress-Archive -Path "$outputDir\*" -DestinationPath $artifactZip -Force
|
||||
}
|
||||
|
||||
return @{ ExitCode = $buildExit; Output = ($output -join "`n") }
|
||||
} -ArgumentList $GuestWorkDir, $Configuration, $GuestArtifactZip
|
||||
|
||||
Write-Host $buildResult.Output
|
||||
if ($buildResult.ExitCode -ne 0) {
|
||||
throw "dotnet build failed (exit $($buildResult.ExitCode))"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[Invoke-RemoteBuild] Build succeeded. Artifact at $GuestArtifactZip"
|
||||
}
|
||||
finally {
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a linked clone of the template VM for an ephemeral CI build.
|
||||
|
||||
.DESCRIPTION
|
||||
Uses vmrun.exe to create a linked clone from a frozen template snapshot.
|
||||
Returns the full path to the new clone's .vmx file on success.
|
||||
The template VM and its snapshot are never modified.
|
||||
|
||||
.PARAMETER TemplatePath
|
||||
Full path to the template VM's .vmx file.
|
||||
Example: F:\CI\Templates\WinBuild\WinBuild.vmx
|
||||
|
||||
.PARAMETER SnapshotName
|
||||
Name of the snapshot on the template VM to clone from.
|
||||
Default: BaseClean
|
||||
|
||||
.PARAMETER CloneBaseDir
|
||||
Directory under which the new clone folder will be created.
|
||||
The clone folder is named after the job ID and timestamp.
|
||||
Example: F:\CI\BuildVMs
|
||||
|
||||
.PARAMETER JobId
|
||||
Unique identifier for the CI job (e.g. run ID from Gitea Actions).
|
||||
Used to name the clone: Clone_{JobId}_{yyyyMMdd_HHmmss}
|
||||
|
||||
.PARAMETER VmrunPath
|
||||
Full path to vmrun.exe.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||
|
||||
.OUTPUTS
|
||||
[string] Full path to the new clone's .vmx file.
|
||||
|
||||
.EXAMPLE
|
||||
$vmxPath = .\New-BuildVM.ps1 `
|
||||
-TemplatePath "F:\CI\Templates\WinBuild\WinBuild.vmx" `
|
||||
-CloneBaseDir "F:\CI\BuildVMs" `
|
||||
-JobId "run-42"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateScript({ Test-Path $_ -PathType Leaf })]
|
||||
[string] $TemplatePath,
|
||||
|
||||
[string] $SnapshotName = 'BaseClean',
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $CloneBaseDir,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string] $JobId,
|
||||
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# --- Validate vmrun.exe ---
|
||||
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
|
||||
throw "vmrun.exe not found at: $VmrunPath`nInstall VMware Workstation Pro and update -VmrunPath if needed."
|
||||
}
|
||||
|
||||
# --- Build clone path ---
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
||||
$cloneName = "Clone_${JobId}_${timestamp}"
|
||||
$cloneDir = Join-Path $CloneBaseDir $cloneName
|
||||
$cloneVmx = Join-Path $cloneDir "$cloneName.vmx"
|
||||
|
||||
# Ensure clone base dir exists
|
||||
if (-not (Test-Path $CloneBaseDir)) {
|
||||
New-Item -ItemType Directory -Path $CloneBaseDir -Force | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "[New-BuildVM] Creating linked clone..."
|
||||
Write-Host " Template : $TemplatePath"
|
||||
Write-Host " Snapshot : $SnapshotName"
|
||||
Write-Host " Clone VMX : $cloneVmx"
|
||||
|
||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
|
||||
# --- Run vmrun clone ---
|
||||
$vmrunArgs = @(
|
||||
'-T', 'ws',
|
||||
'clone',
|
||||
$TemplatePath,
|
||||
$cloneVmx,
|
||||
'linked',
|
||||
'-snapshot', $SnapshotName
|
||||
)
|
||||
|
||||
$result = & $VmrunPath @vmrunArgs 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
|
||||
$sw.Stop()
|
||||
|
||||
if ($exitCode -ne 0) {
|
||||
# Clean up partial clone directory if it was created
|
||||
if (Test-Path $cloneDir) {
|
||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
throw "vmrun clone failed (exit $exitCode).`nOutput: $result"
|
||||
}
|
||||
|
||||
if (-not (Test-Path $cloneVmx -PathType Leaf)) {
|
||||
throw "vmrun reported success but clone VMX not found at: $cloneVmx"
|
||||
}
|
||||
|
||||
Write-Host "[New-BuildVM] Clone created in $($sw.Elapsed.TotalSeconds.ToString('F1'))s : $cloneVmx"
|
||||
|
||||
# Return the VMX path as output
|
||||
return $cloneVmx
|
||||
@@ -0,0 +1,100 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stops and permanently deletes an ephemeral build VM.
|
||||
|
||||
.DESCRIPTION
|
||||
Performs a graceful shutdown (soft stop) with a timeout fallback to
|
||||
a hard stop, then uses vmrun deleteVM to remove all VM files.
|
||||
Finally, removes the clone directory from disk.
|
||||
|
||||
This script is designed to be called from a try/finally block so
|
||||
it always runs, even on build failure.
|
||||
|
||||
.PARAMETER VMPath
|
||||
Full path to the clone VM's .vmx file.
|
||||
|
||||
.PARAMETER VmrunPath
|
||||
Full path to vmrun.exe.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||
|
||||
.PARAMETER GracefulTimeoutSeconds
|
||||
Seconds to wait for graceful shutdown before forcing power-off.
|
||||
Default: 15
|
||||
|
||||
.EXAMPLE
|
||||
.\Remove-BuildVM.ps1 -VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $VMPath,
|
||||
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
||||
|
||||
[ValidateRange(5, 60)]
|
||||
[int] $GracefulTimeoutSeconds = 15
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Continue' # Don't stop on errors — always try next step
|
||||
|
||||
$cloneDir = Split-Path $VMPath -Parent
|
||||
|
||||
Write-Host "[Remove-BuildVM] Destroying VM: $VMPath"
|
||||
|
||||
# ── Step 1: Check if VM exists at all ────────────────────────────────────────
|
||||
if (-not (Test-Path $VMPath -PathType Leaf)) {
|
||||
Write-Warning "[Remove-BuildVM] VMX file not found — nothing to remove: $VMPath"
|
||||
# Still attempt to remove the directory in case of partial clone
|
||||
if (Test-Path $cloneDir) {
|
||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# ── Step 2: Graceful stop ─────────────────────────────────────────────────────
|
||||
Write-Host "[Remove-BuildVM] Sending soft stop..."
|
||||
& $VmrunPath -T ws stop $VMPath soft 2>&1 | Out-Null
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($GracefulTimeoutSeconds)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Seconds 2
|
||||
$stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1
|
||||
if ($stateOutput -notmatch 'running') {
|
||||
Write-Host "[Remove-BuildVM] VM stopped gracefully."
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# ── Step 3: Force stop if still running ──────────────────────────────────────
|
||||
$stateOutput = & $VmrunPath -T ws getState $VMPath 2>&1
|
||||
if ($stateOutput -match 'running') {
|
||||
Write-Host "[Remove-BuildVM] Graceful stop timed out — forcing hard stop..."
|
||||
& $VmrunPath -T ws stop $VMPath hard 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
|
||||
# ── Step 4: Delete VM via vmrun (removes VMX + delta VMDK) ───────────────────
|
||||
Write-Host "[Remove-BuildVM] Deleting VM..."
|
||||
$deleteOutput = & $VmrunPath -T ws deleteVM $VMPath 2>&1
|
||||
$deleteExit = $LASTEXITCODE
|
||||
|
||||
if ($deleteExit -ne 0) {
|
||||
Write-Warning "[Remove-BuildVM] vmrun deleteVM returned exit code $deleteExit : $deleteOutput"
|
||||
Write-Warning "[Remove-BuildVM] Will attempt manual directory removal."
|
||||
}
|
||||
|
||||
# ── Step 5: Remove clone directory (catches any leftover files) ───────────────
|
||||
if (Test-Path $cloneDir) {
|
||||
Write-Host "[Remove-BuildVM] Removing clone directory: $cloneDir"
|
||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
if (Test-Path $cloneDir) {
|
||||
Write-Warning "[Remove-BuildVM] Clone directory could not be fully removed: $cloneDir"
|
||||
Write-Warning " Manual cleanup required."
|
||||
} else {
|
||||
Write-Host "[Remove-BuildVM] Clone directory removed."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[Remove-BuildVM] VM destruction complete."
|
||||
@@ -0,0 +1,134 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Waits until a build VM is fully ready to accept WinRM connections.
|
||||
|
||||
.DESCRIPTION
|
||||
Performs a three-phase readiness check in a polling loop:
|
||||
1. vmrun getState returns "running"
|
||||
2. ICMP ping succeeds
|
||||
3. Test-WSMan (WinRM) responds without error
|
||||
|
||||
Throws if the VM does not become ready within the timeout period.
|
||||
|
||||
.PARAMETER VMPath
|
||||
Full path to the clone VM's .vmx file.
|
||||
|
||||
.PARAMETER IPAddress
|
||||
IP address of the VM on the build network (e.g. 192.168.11.101).
|
||||
|
||||
.PARAMETER TimeoutSeconds
|
||||
Maximum seconds to wait for the VM to become ready.
|
||||
Default: 300 (5 minutes)
|
||||
|
||||
.PARAMETER PollIntervalSeconds
|
||||
Seconds between readiness checks.
|
||||
Default: 5
|
||||
|
||||
.PARAMETER VmrunPath
|
||||
Full path to vmrun.exe.
|
||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
||||
|
||||
.EXAMPLE
|
||||
.\Wait-VMReady.ps1 `
|
||||
-VMPath "F:\CI\BuildVMs\Clone_run-42_20260508_143022\Clone_run-42_20260508_143022.vmx" `
|
||||
-IPAddress "192.168.11.101"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string] $VMPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^(\d{1,3}\.){3}\d{1,3}$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[ValidateRange(30, 600)]
|
||||
[int] $TimeoutSeconds = 300,
|
||||
|
||||
[ValidateRange(3, 30)]
|
||||
[int] $PollIntervalSeconds = 5,
|
||||
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
|
||||
|
||||
# Skip ICMP ping phase (phase 2). Useful when the guest firewall blocks ICMP
|
||||
# but WinRM (TCP 5985) is open. Phase 1 (vmrun state) and phase 3 (WinRM)
|
||||
# are still checked.
|
||||
[switch] $SkipPing
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path $VmrunPath -PathType Leaf)) {
|
||||
throw "vmrun.exe not found at: $VmrunPath"
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$attempt = 0
|
||||
$phase = 'vmrun-state' # tracks current phase for informative output
|
||||
$lastPhase = ''
|
||||
|
||||
Write-Host "[Wait-VMReady] Waiting for VM ready (timeout: ${TimeoutSeconds}s, IP: $IPAddress)..."
|
||||
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
$attempt++
|
||||
|
||||
# ── Phase 1: VM must be running ─────────────────────────────────────
|
||||
# vmrun has no getState; getGuestIPAddress fails with "not powered on"
|
||||
# when the VM is off, and succeeds (exit 0) when running.
|
||||
try {
|
||||
& $VmrunPath getGuestIPAddress $VMPath 2>&1 | Out-Null
|
||||
$isRunning = ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
catch {
|
||||
$isRunning = $false
|
||||
}
|
||||
|
||||
if (-not $isRunning) {
|
||||
if ($phase -ne 'vmrun-state') {
|
||||
$phase = 'vmrun-state'
|
||||
}
|
||||
if ($lastPhase -ne $phase) {
|
||||
Write-Host "[Wait-VMReady] Phase 1/3: waiting for VM to enter running state..."
|
||||
$lastPhase = $phase
|
||||
}
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
continue
|
||||
}
|
||||
|
||||
# ── Phase 2: Network ping (optional) ───────────────────────────────
|
||||
if (-not $SkipPing) {
|
||||
$pingOk = Test-Connection -ComputerName $IPAddress -Count 1 -Quiet -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $pingOk) {
|
||||
$phase = 'ping'
|
||||
if ($lastPhase -ne $phase) {
|
||||
Write-Host "[Wait-VMReady] Phase 2/3: VM running, waiting for network (ping $IPAddress)..."
|
||||
$lastPhase = $phase
|
||||
}
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
# ── Phase 3: WinRM responsive ────────────────────────────────────────
|
||||
try {
|
||||
Test-WSMan -ComputerName $IPAddress -ErrorAction Stop | Out-Null
|
||||
# Success — VM is ready
|
||||
$elapsed = ($deadline - (Get-Date)).Negate().TotalSeconds + $TimeoutSeconds
|
||||
Write-Host "[Wait-VMReady] VM ready after ~$([int]($attempt * $PollIntervalSeconds))s (attempt $attempt)."
|
||||
return
|
||||
}
|
||||
catch {
|
||||
$phase = 'winrm'
|
||||
if ($lastPhase -ne $phase) {
|
||||
Write-Host "[Wait-VMReady] Phase 3/3: ping OK, waiting for WinRM on port 5985..."
|
||||
$lastPhase = $phase
|
||||
}
|
||||
Start-Sleep -Seconds $PollIntervalSeconds
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw "[Wait-VMReady] Timed out after ${TimeoutSeconds}s waiting for VM at $IPAddress to become ready. Last phase: $phase"
|
||||
Reference in New Issue
Block a user