Files
local-ci-cd-system/scripts/Test-CapacityBurnIn.ps1
T
Simone 80eeb389af fix(burn-in): add -SkipArtifact switch; restore production fail-on-missing-dist
PROBLEM: Invoke-RemoteBuild.ps1 was creating a placeholder artifact when the build
produced no dist/ directory. In production this hides build defects (build exits 0
but produces nothing = silent failure treated as success).

SOLUTION: Add explicit -SkipArtifact switch to the call chain:

Invoke-RemoteBuild.ps1:
  - Add -SkipArtifact switch. When set: skips packaging entirely (no zip created).
  - When absent (production default): missing GuestArtifactSource is a hard error
    with a clear message pointing to -SkipArtifact as the opt-in for no-output builds.

Invoke-CIJob.ps1:
  - Add -SkipArtifact switch. Passes it to Invoke-RemoteBuild.ps1.
  - Phase 6 (Get-BuildArtifacts) is entirely skipped when set; logs 'skipped' event.

Test-CapacityBurnIn.ps1:
  - Add -SkipArtifact switch. Adds '-SkipArtifact' flag to each child job argList.

Start-BurnInTest.ps1:
  - Always passes -SkipArtifact by default (burn-in uses delay commands, no output).
  - Add -NoSkipArtifact override switch for runs that do produce real artifacts.
  - Remove -NoSkipArtifact escape from wrapper: default burn-in never needs artifacts.
2026-05-12 21:28:11 +02:00

374 lines
15 KiB
PowerShell

#Requires -Version 5.1
<#
.SYNOPSIS
Concurrency burn-in harness: runs N simultaneous Invoke-CIJob.ps1 jobs
for R rounds and asserts correct cleanup after each round.
.DESCRIPTION
Launches $Parallelism child PowerShell processes (via Start-Job) all
running Invoke-CIJob.ps1 concurrently. After each round verifies that:
- All job exit codes were 0
- No clone directories for burn-in jobs remain in CloneBaseDir
- No stale vm-start.lock file remains (older than 5 min)
Exits 0 only when ALL rounds and assertions pass.
.PARAMETER RepoUrl
Git URL to pass to Invoke-CIJob.ps1 as -RepoUrl.
.PARAMETER Branch
Branch to build. Default: main
.PARAMETER IPList
Optional array of IP addresses, one per concurrent job slot.
When provided, each job receives the corresponding -VMIPAddress (useful when
VMs have static IPs or per-slot DHCP reservations).
When omitted, -VMIPAddress is not passed to Invoke-CIJob.ps1, which then
auto-detects the IP via Get-GuestIPAddress (requires VMware Tools / ci-report-ip.service).
When provided, must have at least $Parallelism entries.
.PARAMETER TemplatePath
VMX template path. Falls back to GITEA_CI_TEMPLATE_PATH env var.
.PARAMETER SnapshotName
Snapshot to clone from. Falls back to GITEA_CI_SNAPSHOT_NAME env var.
Default: BaseClean
.PARAMETER CloneBaseDir
Clone VM base directory. Falls back to GITEA_CI_CLONE_BASE_DIR env var.
Default: F:\CI\BuildVMs
.PARAMETER ArtifactBaseDir
Artifact output directory. Default: F:\CI\Artifacts
.PARAMETER GuestCredentialTarget
Windows Credential Manager target for guest credentials.
Default: BuildVMGuest
.PARAMETER VmrunPath
Path to vmrun.exe.
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.PARAMETER GuestOS
Guest OS transport. Default: Auto (detected from VMX).
.PARAMETER BuildCommand
Custom build command forwarded to Invoke-CIJob.ps1.
Default: empty (uses dotnet build).
.PARAMETER Parallelism
Number of concurrent jobs per round. Default: 4.
.PARAMETER Rounds
Number of sequential rounds. Default: 3.
.PARAMETER RoundTimeoutMinutes
Maximum wait time per round before force-stopping remaining jobs.
Default: 60.
.PARAMETER ScriptDir
Directory containing sibling CI scripts. Default: directory of this script.
.EXAMPLE
.\Test-CapacityBurnIn.ps1 `
-RepoUrl 'http://gitea.local/ci/hello-world.git' `
-IPList '192.168.79.101','192.168.79.102','192.168.79.103','192.168.79.104' `
-Rounds 3 -Parallelism 4
.EXAMPLE
# Quick smoke round (1 round, 2 jobs) — auto-detect IPs via VMware Tools
.\Test-CapacityBurnIn.ps1 `
-RepoUrl 'http://gitea.local/ci/hello-world.git' `
-TemplatePath 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
-Parallelism 2 -Rounds 1
.EXAMPLE
# With explicit static IPs (DHCP reservations configured)
.\Test-CapacityBurnIn.ps1 `
-RepoUrl 'http://gitea.local/ci/hello-world.git' `
-IPList '192.168.79.101','192.168.79.102' `
-TemplatePath 'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx' `
-Parallelism 2 -Rounds 1
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $RepoUrl,
[string[]] $IPList = @(),
[string] $Branch = 'main',
[string] $TemplatePath = $env:GITEA_CI_TEMPLATE_PATH,
[string] $SnapshotName = $(
if ($env:GITEA_CI_SNAPSHOT_NAME) { $env:GITEA_CI_SNAPSHOT_NAME } else { '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',
[string] $GuestCredentialTarget = 'BuildVMGuest',
# Credential Manager target for the Gitea PAT (used by Invoke-CIJob -UseGitClone).
# Store with: New-StoredCredential -Target 'GiteaPAT' -UserName '<user>' -Password '<pat>' -Persist LocalMachine
[string] $GiteaCredentialTarget = 'GiteaPAT',
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
[ValidateSet('Windows', 'Linux', 'Auto')]
[string] $GuestOS = 'Auto',
[string] $BuildCommand = '',
# Skip artifact packaging and collection in every child job.
# Set this for burn-in and smoke runs that use delay-only build commands.
[switch] $SkipArtifact,
[ValidateRange(1, 10)]
[int] $Parallelism = 4,
[ValidateRange(1, 10)]
[int] $Rounds = 3,
[ValidateRange(5, 240)]
[int] $RoundTimeoutMinutes = 60,
[string] $ScriptDir = $PSScriptRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Validate inputs ───────────────────────────────────────────────────────────
if ($IPList.Count -gt 0 -and $IPList.Count -lt $Parallelism) {
throw "IPList must contain at least $Parallelism entries (got $($IPList.Count))."
}
if (-not $TemplatePath) {
throw "TemplatePath is required. Pass -TemplatePath or set GITEA_CI_TEMPLATE_PATH."
}
if (-not (Test-Path $TemplatePath -PathType Leaf)) {
throw "TemplatePath not found: $TemplatePath"
}
# Resolve script dir — $PSScriptRoot may be empty when dot-sourced interactively
if (-not $ScriptDir) {
$ScriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent
}
$invokeCIJobPath = Join-Path $ScriptDir 'Invoke-CIJob.ps1'
if (-not (Test-Path $invokeCIJobPath -PathType Leaf)) {
throw "Invoke-CIJob.ps1 not found at: $invokeCIJobPath"
}
# ── State trackers ────────────────────────────────────────────────────────────
$roundResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$totalPass = 0
$totalFail = 0
$roundsFailed = 0
Write-Host '============================================================'
Write-Host '[Test-CapacityBurnIn] Burn-in configuration:'
Write-Host " Parallelism : $Parallelism"
Write-Host " Rounds : $Rounds"
Write-Host " Round timeout : ${RoundTimeoutMinutes}m"
Write-Host " Template : $TemplatePath"
Write-Host " Snapshot : $SnapshotName"
Write-Host " CloneBaseDir : $CloneBaseDir"
$ipDisplay = if ($IPList.Count -ge $Parallelism) { $IPList[0..($Parallelism - 1)] -join ', ' } else { '(auto-detect via VMware Tools)' }
Write-Host " IPs : $ipDisplay"
Write-Host '============================================================'
for ($r = 1; $r -le $Rounds; $r++) {
Write-Host "`n[Round $r/$Rounds] Launching $Parallelism concurrent jobs..."
$roundStart = Get-Date
# ── Launch jobs ───────────────────────────────────────────────────────
$jobEntries = [System.Collections.Generic.List[hashtable]]::new()
for ($j = 1; $j -le $Parallelism; $j++) {
$jId = "burnin-r${r}-j${j}-$(Get-Date -Format 'HHmmss')"
$ipAddr = if ($IPList.Count -ge $j) { $IPList[$j - 1] } else { '' }
# Build a flat string array for powershell.exe -File
$argList = [System.Collections.Generic.List[string]]::new()
$argList.Add('-NonInteractive')
$argList.Add('-ExecutionPolicy'); $argList.Add('Bypass')
$argList.Add('-File'); $argList.Add($invokeCIJobPath)
$argList.Add('-JobId'); $argList.Add($jId)
$argList.Add('-RepoUrl'); $argList.Add($RepoUrl)
$argList.Add('-Branch'); $argList.Add($Branch)
$argList.Add('-TemplatePath'); $argList.Add($TemplatePath)
$argList.Add('-SnapshotName'); $argList.Add($SnapshotName)
$argList.Add('-CloneBaseDir'); $argList.Add($CloneBaseDir)
$argList.Add('-ArtifactBaseDir'); $argList.Add($ArtifactBaseDir)
if ($ipAddr -ne '') {
$argList.Add('-VMIPAddress'); $argList.Add($ipAddr)
}
$argList.Add('-GuestCredentialTarget'); $argList.Add($GuestCredentialTarget)
$argList.Add('-GiteaCredentialTarget'); $argList.Add($GiteaCredentialTarget)
$argList.Add('-VmrunPath'); $argList.Add($VmrunPath)
$argList.Add('-GuestOS'); $argList.Add($GuestOS)
if ($BuildCommand -ne '') {
$argList.Add('-BuildCommand'); $argList.Add($BuildCommand)
}
if ($SkipArtifact) {
$argList.Add('-SkipArtifact')
}
$argArray = $argList.ToArray()
# Start-Job runs a new PS session; call powershell.exe as child process
# so exit $exitCode from Invoke-CIJob.ps1 is captured in $LASTEXITCODE.
# NOTE: do NOT name the parameter $Args — @Args is a PS reserved splatting
# alias for the automatic $Args variable (unbound arguments), which would
# be empty here since the parameter binding consumed all positional args.
$psJob = Start-Job -ScriptBlock {
param([string[]]$ChildArgs)
$out = & powershell.exe @ChildArgs 2>&1
[PSCustomObject]@{
ExitCode = $LASTEXITCODE
Output = ($out -join "`n")
}
} -ArgumentList (, $argArray)
$jobEntries.Add(@{
PSJob = $psJob
JobId = $jId
IP = $ipAddr
Slot = $j
})
$ipDisplay2 = if ($ipAddr -ne '') { $ipAddr } else { '(auto)' }
Write-Host " Slot $j — JobId=$jId IP=$ipDisplay2 PSJob=$($psJob.Id)"
}
# ── Wait for round to complete ────────────────────────────────────────
Write-Host " Waiting (timeout: ${RoundTimeoutMinutes}m)..."
$psJobArray = @($jobEntries | ForEach-Object { $_.PSJob })
$null = Wait-Job -Job $psJobArray -Timeout ($RoundTimeoutMinutes * 60)
# Force-stop anything still running (timeout exceeded)
foreach ($entry in $jobEntries) {
if ($entry.PSJob.State -eq 'Running') {
Write-Warning " [Round $r] Slot $($entry.Slot) ($($entry.JobId)) still running after timeout — stopping."
Stop-Job $entry.PSJob
}
}
$roundElapsed = (Get-Date) - $roundStart
$roundPass = 0
$roundFail = 0
# ── Print per-job results ─────────────────────────────────────────────
Write-Host ''
Write-Host (' ' + ('{0,-44} {1,-16} {2,-8} {3}' -f 'JobId', 'IP', 'Status', 'ExitCode'))
Write-Host (' ' + ('{0,-44} {1,-16} {2,-8} {3}' -f ('-' * 44), ('-' * 16), ('-' * 8), ('-' * 8)))
foreach ($entry in $jobEntries) {
$savedState = $entry.PSJob.State
$result = Receive-Job $entry.PSJob -ErrorAction SilentlyContinue
Remove-Job $entry.PSJob -Force
if ($savedState -eq 'Stopped') {
$status = 'TIMEOUT'; $ec = -1
} elseif ($null -eq $result) {
$status = 'NO-RESULT'; $ec = -1
} else {
$ec = $result.ExitCode
$status = if ($ec -eq 0) { 'PASS' } else { 'FAIL' }
}
Write-Host (' ' + ('{0,-44} {1,-16} {2,-8} {3}' -f $entry.JobId, $entry.IP, $status, $ec))
# On failure, print the last 20 lines of output to aid diagnosis
if ($status -ne 'PASS' -and $null -ne $result -and $result.Output -ne '') {
$lines = $result.Output -split "`n"
$tail = if ($lines.Count -gt 20) { $lines[-20..-1] } else { $lines }
Write-Host ' --- last output ---'
foreach ($line in $tail) { Write-Host " $line" }
Write-Host ' ---'
}
if ($status -eq 'PASS') { $roundPass++ } else { $roundFail++ }
}
# ── Assert: no orphaned clone dirs ────────────────────────────────────
$orphaned = [System.Collections.Generic.List[string]]::new()
if (Test-Path $CloneBaseDir) {
foreach ($entry in $jobEntries) {
$dirs = Get-ChildItem -Path $CloneBaseDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "Clone_$($entry.JobId)_*" }
if ($dirs) {
foreach ($d in $dirs) { $orphaned.Add($d.FullName) }
}
}
}
Write-Host ''
if ($orphaned.Count -eq 0) {
Write-Host " Clone cleanup : OK (0 orphaned)"
} else {
Write-Host " Clone cleanup : FAIL ($($orphaned.Count) orphaned directories)"
foreach ($p in $orphaned) { Write-Host " $p" }
$roundFail++
}
# ── Assert: no stale lock file ────────────────────────────────────────
$lockPath = Join-Path $CloneBaseDir 'vm-start.lock'
$staleLock = $false
if (Test-Path $lockPath) {
$ageMin = ((Get-Date) - (Get-Item $lockPath).LastWriteTime).TotalMinutes
if ($ageMin -gt 5) {
$staleLock = $true
}
}
if (-not $staleLock) {
Write-Host " Lock cleanup : OK"
} else {
Write-Host " Lock cleanup : FAIL (stale vm-start.lock present)"
$roundFail++
}
$roundStatus = if ($roundFail -eq 0) { 'PASS' } else { 'FAIL' }
Write-Host (" [Round $r] Elapsed: {0:mm\:ss} -- $roundStatus ($roundPass PASS, $roundFail FAIL)" -f $roundElapsed)
$totalPass += $roundPass
$totalFail += $roundFail
if ($roundFail -gt 0) { $roundsFailed++ }
$roundResults.Add([PSCustomObject]@{
Round = $r
Pass = $roundPass
Fail = $roundFail
Status = $roundStatus
ElapsedSec = [int]$roundElapsed.TotalSeconds
})
}
# ── Overall summary ───────────────────────────────────────────────────────────
Write-Host "`n============================================================"
Write-Host '[Test-CapacityBurnIn] SUMMARY'
Write-Host (' {0,-8} {1,-6} {2,-6} {3,-10} {4}' -f 'Round', 'Pass', 'Fail', 'Elapsed', 'Status')
Write-Host (' {0,-8} {1,-6} {2,-6} {3,-10} {4}' -f ('-'*8), ('-'*6), ('-'*6), ('-'*10), ('-'*8))
foreach ($rr in $roundResults) {
Write-Host (' {0,-8} {1,-6} {2,-6} {3,-10} {4}' -f $rr.Round, $rr.Pass, $rr.Fail, "$($rr.ElapsedSec)s", $rr.Status)
}
Write-Host ''
Write-Host " Total jobs : $($Rounds * $Parallelism)"
Write-Host " Passed : $totalPass"
Write-Host " Failed : $totalFail"
Write-Host " Rounds OK : $($Rounds - $roundsFailed) / $Rounds"
$overallStatus = if ($totalFail -eq 0) { 'PASS' } else { 'FAIL' }
Write-Host "`n OVERALL: $overallStatus"
Write-Host '============================================================'
if ($totalFail -gt 0) {
exit 1
}
exit 0