chore(scripts): archive PowerShell scripts superseded by the Python CLI

Move 16 .ps1 files to scripts/archived/ — they have no live caller:

- 10 pure shims (New-BuildVM, Remove-BuildVM, Cleanup-OrphanedBuildVMs,
  Wait-VMReady, Invoke-RemoteBuild, Get-BuildArtifacts, Watch-DiskSpace,
  Watch-RunnerHealth, Get-CIJobSummary, Invoke-CIJob) that only forwarded
  to `ci_orchestrator`; the live Gitea composite action now calls
  `python -m ci_orchestrator job` directly.
- 6 Phase-C host tools reimplemented natively in Python (Start-BurnInTest
  [+ -Linux], Test-CapacityBurnIn → bench; Test-Smoke → smoke run;
  Validate-HostState → validate host; Set-CIGuestCredential → creds set).

Add scripts/archived/README.md mapping each file to its `ci` replacement,
and exempt scripts/archived/ from the PSScriptAnalyzer lint job.

Kept (still used / not yet ported): Register-CIScheduledTasks,
Backup-CITemplate, Invoke-RetentionPolicy, Set-TemplateSharedFolders,
Test-CIGuestWinRM, the e2e build harnesses, and all template/ guest
provisioning scripts.
This commit is contained in:
2026-06-08 21:15:05 +02:00
parent cda4276d9e
commit 0b0d5486f0
18 changed files with 42 additions and 0 deletions
@@ -0,0 +1,44 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI.
# Original PS implementation moved to git history; see plans/A2-closeout.md.
# PowerShell common params (-ErrorAction etc.) must not be forwarded
# to the Python CLI. Value-taking ones also consume the next token.
$commonWithValue = @{
erroraction = $true; warningaction = $true; informationaction = $true
progressaction = $true; errorvariable = $true; warningvariable = $true
informationvariable = $true; outvariable = $true; outbuffer = $true
pipelinevariable = $true
}
$commonSwitch = @{ verbose = $true; debug = $true; whatif = $true; confirm = $true }
$pyArgs = @()
for ($i = 0; $i -lt $args.Count; $i++) {
$a = $args[$i]
if ($a -is [string] -and $a.Length -gt 1 -and $a.StartsWith('-') -and -not $a.StartsWith('--')) {
$name = $a.Substring(1)
$lname = $name.ToLower()
if ($commonSwitch.ContainsKey($lname)) { continue }
if ($commonWithValue.ContainsKey($lname)) { $i++; continue }
$kebab = [Regex]::Replace($name, '(?<=[a-z0-9])([A-Z])', '-$1')
$kebab = [Regex]::Replace($kebab, '([A-Z]+)([A-Z][a-z])', '$1-$2')
$pyArgs += '--' + $kebab.ToLower()
}
else {
$pyArgs += $a
}
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator vm cleanup @pyArgs
exit $LASTEXITCODE
+69
View File
@@ -0,0 +1,69 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI (`artifacts collect`).
# Original PS implementation moved to git history; see plans/A3-closeout.md.
#
# The bound `-Credential` is intentionally discarded — Python uses keyring
# via `--credential-target` (defaulting to `BuildVMGuest`).
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $IPAddress,
[Parameter()]
[System.Management.Automation.PSCredential] $Credential,
[Parameter(Mandatory)]
[string] $GuestArtifactPath,
[Parameter(Mandatory)]
[string] $HostArtifactDir,
[switch] $IncludeLogs,
[ValidateSet('Windows', 'Linux')]
[string] $GuestOS = 'Windows',
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
[string] $SshUser = 'ci_build',
[string] $SshKnownHostsFile = '',
[string] $JobId = '',
[string] $Commit = '',
[string] $CredentialTarget = ''
)
$pyArgs = @(
'--ip-address', $IPAddress,
'--guest-os', $GuestOS.ToLower(),
'--guest-artifact-path', $GuestArtifactPath,
'--host-artifact-dir', $HostArtifactDir,
'--ssh-user', $SshUser,
'--ssh-key-path', $SshKeyPath,
'--job-id', $JobId,
'--commit', $Commit
)
if ($SshKnownHostsFile) { $pyArgs += @('--ssh-known-hosts-file', $SshKnownHostsFile) }
if ($CredentialTarget) { $pyArgs += @('--credential-target', $CredentialTarget) }
if ($IncludeLogs.IsPresent) { $pyArgs += '--include-logs' }
if ($Credential) {
Write-Host '[Get-BuildArtifacts] -Credential ignored; Python CLI uses keyring (set -CredentialTarget to override target name).'
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator artifacts collect @pyArgs
exit $LASTEXITCODE
+222
View File
@@ -0,0 +1,222 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI.
# Original PS implementation moved to git history; see plans/A2-closeout.md.
# PowerShell common params (-ErrorAction etc.) must not be forwarded
# to the Python CLI. Value-taking ones also consume the next token.
$commonWithValue = @{
erroraction = $true; warningaction = $true; informationaction = $true
progressaction = $true; errorvariable = $true; warningvariable = $true
informationvariable = $true; outvariable = $true; outbuffer = $true
pipelinevariable = $true
}
$commonSwitch = @{ verbose = $true; debug = $true; whatif = $true; confirm = $true }
$pyArgs = @()
for ($i = 0; $i -lt $args.Count; $i++) {
$a = $args[$i]
if ($a -is [string] -and $a.Length -gt 1 -and $a.StartsWith('-') -and -not $a.StartsWith('--')) {
$name = $a.Substring(1)
$lname = $name.ToLower()
if ($commonSwitch.ContainsKey($lname)) { continue }
if ($commonWithValue.ContainsKey($lname)) { $i++; continue }
$kebab = [Regex]::Replace($name, '(?<=[a-z0-9])([A-Z])', '-$1')
$kebab = [Regex]::Replace($kebab, '([A-Z]+)([A-Z][a-z])', '$1-$2')
$pyArgs += '--' + $kebab.ToLower()
}
else {
$pyArgs += $a
}
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator report job @pyArgs
exit $LASTEXITCODE
.DESCRIPTION
Scans F:\CI\Logs\ (or a custom path) for invoke-ci.jsonl files and
prints a compact table with job ID, status, total elapsed time, and the
last error message (if any).
Each row corresponds to one completed (or failed) CI job.
Use -JobId to inspect a single job in detail.
.PARAMETER LogDir
Base directory containing per-job log subdirectories.
Default: F:\CI\Logs
.PARAMETER Last
Show only the N most recent jobs (sorted by job-start timestamp).
Default: 20
.PARAMETER JobId
When specified, shows a detailed phase-by-phase breakdown for that job.
.PARAMETER Failed
When specified, filters to failed jobs only.
.EXAMPLE
# Show last 20 jobs
.\Get-CIJobSummary.ps1
.EXAMPLE
# Show last 5 failed jobs
.\Get-CIJobSummary.ps1 -Last 5 -Failed
.EXAMPLE
# Detailed breakdown for a specific job
.\Get-CIJobSummary.ps1 -JobId 'run-12345'
#>
[CmdletBinding()]
param(
[string] $LogDir = 'F:\CI\Logs',
[ValidateRange(1, 1000)]
[int] $Last = 20,
[string] $JobId = '',
[switch] $Failed
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Helpers ────────────────────────────────────────────────────────────────────
function Format-SecToHMS {
param([int] $Seconds)
if ($Seconds -lt 0) { return '?' }
$h = [math]::Floor($Seconds / 3600)
$m = [math]::Floor(($Seconds % 3600) / 60)
$s = $Seconds % 60
if ($h -gt 0) { return '{0}h{1:00}m{2:00}s' -f $h, $m, $s }
return '{0}m{1:00}s' -f $m, $s
}
function Read-JobEvents {
param([string] $JsonlPath)
$events = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($line in (Get-Content $JsonlPath -Encoding UTF8 -ErrorAction SilentlyContinue)) {
$line = $line.Trim()
if (-not $line) { continue }
try {
$events.Add(($line | ConvertFrom-Json))
} catch {
Write-Debug "[Get-CIJobSummary] Skipping malformed JSONL line in $JsonlPath"
}
}
return $events
}
# ── Validate log dir ────────────────────────────────────────────────────────
if (-not (Test-Path $LogDir -PathType Container)) {
Write-Warning "Log directory not found: $LogDir"
exit 1
}
# ── Single-job detail mode ──────────────────────────────────────────────────
if ($JobId -ne '') {
$jsonlPath = Join-Path $LogDir (Join-Path $JobId 'invoke-ci.jsonl')
if (-not (Test-Path $jsonlPath)) {
Write-Warning "No JSONL log found for job '$JobId' at: $jsonlPath"
exit 1
}
$events = Read-JobEvents -JsonlPath $jsonlPath
if ($events.Count -eq 0) {
Write-Warning "JSONL file is empty or unreadable: $jsonlPath"
exit 1
}
Write-Host "`nJob: $JobId"
Write-Host ('=' * 60)
Write-Host ('{0,-35} {1,-10} {2}' -f 'Phase', 'Status', 'Timestamp')
Write-Host ('{0,-35} {1,-10} {2}' -f ('-'*35), ('-'*10), ('-'*24))
foreach ($ev in $events) {
$ts = if ($ev.ts) { $ev.ts } else { '' }
Write-Host ('{0,-35} {1,-10} {2}' -f $ev.phase, $ev.status, $ts)
}
# Print error if present
$failEvent = $events | Where-Object { $_.status -eq 'failure' } | Select-Object -Last 1
if ($failEvent -and $failEvent.data -and $failEvent.data.error) {
Write-Host "`nError: $($failEvent.data.error)"
}
exit 0
}
# ── Scan all job JSONL files ─────────────────────────────────────────────────
$jsonlFiles = Get-ChildItem -Path $LogDir -Recurse -Filter 'invoke-ci.jsonl' -ErrorAction SilentlyContinue |
Sort-Object { $_.LastWriteTime } -Descending
if ($jsonlFiles.Count -eq 0) {
Write-Host "No invoke-ci.jsonl files found under $LogDir"
exit 0
}
$rows = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($file in $jsonlFiles) {
$events = Read-JobEvents -JsonlPath $file.FullName
if ($events.Count -eq 0) { continue }
$jobEvent = $events | Where-Object { $_.phase -eq 'job' -and $_.status -eq 'start' } | Select-Object -First 1
$successEvent= $events | Where-Object { $_.phase -eq 'job' -and $_.status -eq 'success' } | Select-Object -First 1
$failEvent = $events | Where-Object { $_.phase -eq 'job' -and $_.status -eq 'failure' } | Select-Object -First 1
$jId = if ($events[0].jobId) { $events[0].jobId } else { (Split-Path (Split-Path $file.FullName -Parent) -Leaf) }
$started = if ($jobEvent -and $jobEvent.ts) { $jobEvent.ts } else { '' }
if ($successEvent) {
$status = 'success'
$elSec = if ($successEvent.data -and $null -ne $successEvent.data.elapsedSec) { [int]$successEvent.data.elapsedSec } else { -1 }
$errMsg = ''
} elseif ($failEvent) {
$status = 'FAILED'
$elSec = if ($failEvent.data -and $null -ne $failEvent.data.elapsedSec) { [int]$failEvent.data.elapsedSec } else { -1 }
$errMsg = if ($failEvent.data -and $failEvent.data.error) { "$($failEvent.data.error)" } else { '' }
# Truncate error for table display
if ($errMsg.Length -gt 60) { $errMsg = $errMsg.Substring(0, 57) + '...' }
} else {
$status = 'in-progress'
$elSec = -1
$errMsg = ''
}
$rows.Add([PSCustomObject]@{
JobId = $jId
Status = $status
Elapsed = Format-SecToHMS -Seconds $elSec
Started = $started
Error = $errMsg
})
}
# ── Apply filters + limit ────────────────────────────────────────────────────
if ($Failed) {
$rows = @($rows | Where-Object { $_.Status -eq 'FAILED' })
}
$rows = @($rows | Select-Object -First $Last)
if ($rows.Count -eq 0) {
Write-Host "No matching jobs found."
exit 0
}
# ── Print table ──────────────────────────────────────────────────────────────
Write-Host "`nCI Job Summary (last $($rows.Count) jobs):"
Write-Host ('{0,-40} {1,-12} {2,-10} {3,-26} {4}' -f 'JobId', 'Status', 'Elapsed', 'Started', 'Error')
Write-Host ('{0,-40} {1,-12} {2,-10} {3,-26} {4}' -f ('-'*40), ('-'*12), ('-'*10), ('-'*26), ('-'*30))
foreach ($r in $rows) {
Write-Host ('{0,-40} {1,-12} {2,-10} {3,-26} {4}' -f $r.JobId, $r.Status, $r.Elapsed, $r.Started, $r.Error)
}
Write-Host ''
+44
View File
@@ -0,0 +1,44 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI.
# Original PS implementation moved to git history; see plans/A4-closeout.md.
# PowerShell common params (-ErrorAction etc.) must not be forwarded
# to the Python CLI. Value-taking ones also consume the next token.
$commonWithValue = @{
erroraction = $true; warningaction = $true; informationaction = $true
progressaction = $true; errorvariable = $true; warningvariable = $true
informationvariable = $true; outvariable = $true; outbuffer = $true
pipelinevariable = $true
}
$commonSwitch = @{ verbose = $true; debug = $true; whatif = $true; confirm = $true }
$pyArgs = @()
for ($i = 0; $i -lt $args.Count; $i++) {
$a = $args[$i]
if ($a -is [string] -and $a.Length -gt 1 -and $a.StartsWith('-') -and -not $a.StartsWith('--')) {
$name = $a.Substring(1)
$lname = $name.ToLower()
if ($commonSwitch.ContainsKey($lname)) { continue }
if ($commonWithValue.ContainsKey($lname)) { $i++; continue }
$kebab = [Regex]::Replace($name, '(?<=[a-z0-9])([A-Z])', '-$1')
$kebab = [Regex]::Replace($kebab, '([A-Z]+)([A-Z][a-z])', '$1-$2')
$pyArgs += '--' + $kebab.ToLower()
}
else {
$pyArgs += $a
}
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator job @pyArgs
exit $LASTEXITCODE
+98
View File
@@ -0,0 +1,98 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI (`build run`).
# Original PS implementation moved to git history; see plans/A3-closeout.md.
#
# Typed parameters (`[PSCredential]`, `[hashtable]`, `[switch]`) cannot cross
# a process boundary as-is; this shim translates them. Credentials are pulled
# inside Python via keyring (`--credential-target`), so the bound `-Credential`
# is intentionally discarded — callers should set `-CredentialTarget` instead
# (or rely on the default `BuildVMGuest` resolved by the Python CLI).
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $IPAddress,
[Parameter()]
[System.Management.Automation.PSCredential] $Credential,
[string] $HostSourceDir = '',
[string] $CloneUrl = '',
[string] $CloneBranch = 'main',
[string] $CloneCommit = '',
[switch] $CloneSubmodules,
[string] $GiteaCredentialTarget = 'GiteaPAT',
[string] $Configuration = 'Release',
[string] $BuildCommand = '',
[string] $GuestArtifactSource = 'dist',
[string] $GuestWorkDir = 'C:\CI\build',
[string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip',
[switch] $UseSharedCache,
[switch] $SkipArtifact,
[ValidateSet('Windows', 'Linux')]
[string] $GuestOS = 'Windows',
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
[string] $SshUser = 'ci_build',
[string] $SshKnownHostsFile = '',
[string] $GuestLinuxWorkDir = '/opt/ci/build',
[string] $GuestLinuxOutputDir = '/opt/ci/output',
[hashtable] $ExtraGuestEnv = @{},
# Optional override for the Credential Manager target (overrides any
# fallback resolved by Python). Discards $Credential.
[string] $CredentialTarget = ''
)
$pyArgs = @(
'--ip-address', $IPAddress,
'--guest-os', $GuestOS.ToLower(),
'--ssh-user', $SshUser,
'--ssh-key-path', $SshKeyPath,
'--clone-branch', $CloneBranch,
'--gitea-credential-target', $GiteaCredentialTarget,
'--configuration', $Configuration,
'--guest-work-dir', $GuestWorkDir,
'--guest-artifact-zip', $GuestArtifactZip,
'--guest-linux-work-dir', $GuestLinuxWorkDir,
'--guest-linux-output-dir', $GuestLinuxOutputDir,
'--guest-artifact-source', $GuestArtifactSource
)
if ($HostSourceDir) { $pyArgs += @('--host-source-dir', $HostSourceDir) }
if ($CloneUrl) { $pyArgs += @('--clone-url', $CloneUrl) }
if ($CloneCommit) { $pyArgs += @('--clone-commit', $CloneCommit) }
if ($BuildCommand) { $pyArgs += @('--build-command', $BuildCommand) }
if ($SshKnownHostsFile) { $pyArgs += @('--ssh-known-hosts-file', $SshKnownHostsFile) }
if ($CredentialTarget) { $pyArgs += @('--credential-target', $CredentialTarget) }
if ($CloneSubmodules.IsPresent) { $pyArgs += '--clone-submodules' }
if ($UseSharedCache.IsPresent) { $pyArgs += '--use-shared-cache' }
if ($SkipArtifact.IsPresent) { $pyArgs += '--skip-artifact' }
if ($ExtraGuestEnv -and $ExtraGuestEnv.Count -gt 0) {
foreach ($key in $ExtraGuestEnv.Keys) {
$pyArgs += @('--extra-env', "$key=$($ExtraGuestEnv[$key])")
}
}
if ($Credential) {
Write-Host '[Invoke-RemoteBuild] -Credential ignored; Python CLI uses keyring (set -CredentialTarget to override target name).'
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator build run @pyArgs
exit $LASTEXITCODE
+43
View File
@@ -0,0 +1,43 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI (`vm new`).
# Original PS implementation moved to git history; see plans/A3-closeout.md.
[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'
)
$pyArgs = @(
'--template-path', $TemplatePath,
'--snapshot-name', $SnapshotName,
'--clone-base-dir', $CloneBaseDir,
'--job-id', $JobId,
'--vmrun-path', $VmrunPath
)
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator vm new @pyArgs
exit $LASTEXITCODE
+39
View File
@@ -0,0 +1,39 @@
# Archived PowerShell scripts
These `.ps1` files are **frozen** and no longer part of the live pipeline. They
are kept for history and reference only. Do not call them and do not extend
them — the production entry point is the Python orchestrator (`ci ...` /
`python -m ci_orchestrator`).
They are exempt from PSScriptAnalyzer lint (see `.gitea/workflows/lint.yml`).
## Why archived
**Pure shims** — these only forwarded their arguments to the Python CLI. The
live Gitea composite action (`.gitea/actions/local-ci-build/action.yml`) now
invokes `python -m ci_orchestrator job` directly, so the shims have no caller.
| Archived shim | Python replacement |
|---------------|--------------------|
| `Invoke-CIJob.ps1` | `ci job` |
| `New-BuildVM.ps1` | `ci vm new` |
| `Remove-BuildVM.ps1` | `ci vm remove` |
| `Cleanup-OrphanedBuildVMs.ps1` | `ci vm cleanup` |
| `Wait-VMReady.ps1` | `ci wait-ready` |
| `Invoke-RemoteBuild.ps1` | `ci build run` |
| `Get-BuildArtifacts.ps1` | `ci artifacts collect` |
| `Watch-DiskSpace.ps1` | `ci monitor disk` |
| `Watch-RunnerHealth.ps1` | `ci monitor runner` |
| `Get-CIJobSummary.ps1` | `ci report job` |
**Phase C host tooling** — real PowerShell logic that ran on the Linux host via
`pwsh`, now reimplemented natively in Python so the Linux host needs no pwsh.
| Archived script | Python replacement |
|-----------------|--------------------|
| `Start-BurnInTest.ps1` | `ci bench run` |
| `Start-BurnInTest-Linux.ps1` | `ci bench run` |
| `Test-CapacityBurnIn.ps1` | `ci bench run` / `ci bench measure` |
| `Test-Smoke.ps1` | `ci smoke run` |
| `Validate-HostState.ps1` | `ci validate host` |
| `Set-CIGuestCredential.ps1` | `ci creds set` |
+47
View File
@@ -0,0 +1,47 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI.
# Original PS implementation moved to git history; see plans/A2-closeout.md.
#
# Callers (e.g. Measure-CIBenchmark.ps1) may pass PowerShell common
# parameters like -ErrorAction SilentlyContinue. Those must NOT be
# forwarded as --error-action etc. (the Python CLI rejects them).
# Value-taking common params consume the following token too.
$commonWithValue = @{
erroraction = $true; warningaction = $true; informationaction = $true
progressaction = $true; errorvariable = $true; warningvariable = $true
informationvariable = $true; outvariable = $true; outbuffer = $true
pipelinevariable = $true
}
$commonSwitch = @{ verbose = $true; debug = $true; whatif = $true; confirm = $true }
$pyArgs = @()
for ($i = 0; $i -lt $args.Count; $i++) {
$a = $args[$i]
if ($a -is [string] -and $a.Length -gt 1 -and $a.StartsWith('-') -and -not $a.StartsWith('--')) {
$name = $a.Substring(1)
$lname = $name.ToLower()
if ($commonSwitch.ContainsKey($lname)) { continue }
if ($commonWithValue.ContainsKey($lname)) { $i++; continue }
$kebab = [Regex]::Replace($name, '(?<=[a-z0-9])([A-Z])', '-$1')
$kebab = [Regex]::Replace($kebab, '([A-Z]+)([A-Z][a-z])', '$1-$2')
$pyArgs += '--' + $kebab.ToLower()
}
else {
$pyArgs += $a
}
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator vm remove @pyArgs
exit $LASTEXITCODE
+218
View File
@@ -0,0 +1,218 @@
#Requires -Version 5.1
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Stores a CI guest-VM credential into the LocalSystem keyring vault.
.DESCRIPTION
act_runner runs as the LocalSystem service account. Windows Credential
Manager / keyring vaults are per-user, so a credential added with
`cmdkey` or the Credential Manager UI under an interactive user is
invisible to the runner. The orchestrator then fails with:
Credential '<target>' not found in keyring.
This script writes the credential into the SYSTEM vault using the
production venv's `keyring` itself (the exact backend
`ci_orchestrator` reads with `keyring.get_credential(target, None)`),
via a transient SYSTEM scheduled task. Using keyring for both write
and read avoids cmdkey/keyring format and persistence mismatches.
Known-issue references:
plans/A1-closeout.md (KeyError: 'BuildVMGuest')
plans/implementation-plan-A-B.md (keyring under SYSTEM)
The plaintext password unavoidably transits a temporary .py file.
The file is created with an Administrators/SYSTEM-only ACL, overwritten
with random bytes, then deleted. The password is never placed in a
scheduled-task argument or the command line.
.PARAMETER Target
Credential target name. Must match the runner env
GITEA_CI_GUEST_CRED_TARGET. Default: BuildVMGuest
.PARAMETER UserName
Guest login username in the form the transport expects (e.g.
'WINBUILD\ci_build', '.\ci_build', or 'ci_build').
.PARAMETER Password
Guest password as a SecureString. If omitted, prompted securely.
.PARAMETER VenvPython
Path to the production venv interpreter act_runner uses. Default:
CI_VENV_PYTHON env var, else F:\CI\python\venv\Scripts\python.exe
.PARAMETER SkipVerify
Skip the post-write read-back verification (also run as SYSTEM).
.EXAMPLE
.\Set-CIGuestCredential.ps1 -UserName 'WINBUILD\ci_build'
.EXAMPLE
# Non-interactive (e.g. from another provisioning script)
$pw = Read-Host 'pw' -AsSecureString
.\Set-CIGuestCredential.ps1 -Target BuildVMGuest -UserName '.\ci_build' -Password $pw
.EXAMPLE
.\Set-CIGuestCredential.ps1 -UserName '.\ci_build' -WhatIf
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[ValidateNotNullOrEmpty()]
[string] $Target = 'BuildVMGuest',
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $UserName,
[System.Security.SecureString] $Password,
[string] $VenvPython = $(if ($env:CI_VENV_PYTHON) { $env:CI_VENV_PYTHON }
elseif ($null -ne $IsWindows -and $IsWindows -eq $false) { '/opt/ci/venv/bin/python' }
else { 'F:\CI\python\venv\Scripts\python.exe' }),
[switch] $SkipVerify
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $VenvPython)) {
throw "venv interpreter not found: '$VenvPython'. Set -VenvPython or CI_VENV_PYTHON."
}
if (-not $Password) {
$Password = Read-Host "Password for '$UserName'" -AsSecureString
}
# --- Run a script block as SYSTEM via a transient scheduled task ----------
function Invoke-AsSystem {
param(
[Parameter(Mandatory)] [string] $TaskName,
[Parameter(Mandatory)] [string] $Execute,
[Parameter(Mandatory)] [string] $Argument,
[int] $TimeoutSec = 30
)
$action = New-ScheduledTaskAction -Execute $Execute -Argument $Argument
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' `
-LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $TaskName -Action $action `
-Principal $principal -Force | Out-Null
try {
Start-ScheduledTask -TaskName $TaskName
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
Start-Sleep -Milliseconds 500
$info = Get-ScheduledTaskInfo -TaskName $TaskName
$state = (Get-ScheduledTask -TaskName $TaskName).State
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
return $info.LastTaskResult
}
finally {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false `
-ErrorAction SilentlyContinue
}
}
# --- Harden + shred helper for the transient secret file ------------------
function Remove-Securely {
# Best-effort cleanup helper; ShouldProcess prompting is undesirable
# for an internal shred-and-delete of our own temp file.
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '')]
[CmdletBinding()]
param([Parameter(Mandatory)] [string] $Path)
if (-not (Test-Path -LiteralPath $Path)) { return }
try {
$len = (Get-Item -LiteralPath $Path).Length
if ($len -gt 0) {
$rnd = New-Object byte[] $len
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($rnd)
[IO.File]::WriteAllBytes($Path, $rnd)
}
} catch {
Write-Verbose "secure overwrite failed (continuing to delete): $_"
}
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
}
$tmpDir = $env:TEMP
$setPy = Join-Path $tmpDir ("setcred_{0}.py" -f ([guid]::NewGuid().ToString('N')))
$verPy = Join-Path $tmpDir ("vercred_{0}.py" -f ([guid]::NewGuid().ToString('N')))
$verOut = Join-Path $tmpDir ("vercred_{0}.txt" -f ([guid]::NewGuid().ToString('N')))
if (-not $PSCmdlet.ShouldProcess("$Target ($UserName)",
"store credential in SYSTEM keyring vault")) {
return
}
try {
# Decrypt SecureString only at the moment of writing the temp script.
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)
$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
# Python literals: pass via triple-quoted raw-ish strings. Reject quotes
# that would break out of the literal rather than risk silent corruption.
if ($UserName -match "'''" -or $plain -match "'''") {
throw "UserName/Password contains a triple-quote sequence; unsupported."
}
$setBody = @"
import keyring
keyring.set_password(r'''$Target''', r'''$UserName''', r'''$plain''')
print('OK')
"@
Set-Content -LiteralPath $setPy -Value $setBody -Encoding UTF8
Remove-Variable plain -ErrorAction SilentlyContinue
# Lock the secret file down to SYSTEM + Administrators before it runs.
$acl = New-Object Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
foreach ($id in 'SYSTEM', 'Administrators') {
$acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule(
$id, 'FullControl', 'Allow')))
}
Set-Acl -LiteralPath $setPy -AclObject $acl
$rc = Invoke-AsSystem -TaskName 'CI-SetGuestCred' `
-Execute $VenvPython -Argument "`"$setPy`""
if ($rc -ne 0) {
throw "keyring.set_password task exited with code $rc."
}
Write-Host "Stored '$Target' (user '$UserName') in the SYSTEM vault." `
-ForegroundColor Green
}
finally {
Remove-Securely -Path $setPy
}
if ($SkipVerify) { return }
try {
$verBody = @"
import keyring
c = keyring.get_credential(r'''$Target''', None)
open(r'''$verOut''', 'w', encoding='utf-8').write(
'NONE' if c is None else 'OK:' + c.username)
"@
Set-Content -LiteralPath $verPy -Value $verBody -Encoding UTF8
Invoke-AsSystem -TaskName 'CI-VerifyGuestCred' `
-Execute $VenvPython -Argument "`"$verPy`"" | Out-Null
$result = if (Test-Path -LiteralPath $verOut) {
(Get-Content -LiteralPath $verOut -Raw).Trim()
} else { 'NONE' }
if ($result -like 'OK:*') {
Write-Host "Verified: SYSTEM keyring returns user '$($result.Substring(3))'." `
-ForegroundColor Green
}
else {
throw "Verification failed: SYSTEM keyring.get_credential('$Target', None) " +
"returned None. Credential did not persist."
}
}
finally {
Remove-Item -LiteralPath $verPy -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $verOut -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,73 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Wrapper di lancio per Test-CapacityBurnIn.ps1 con il template Linux (LinuxBuild2404).
.DESCRIPTION
Esegue il burn-in a 4 job / 3 round contro il repo burnin-dummy su Gitea,
usando il template Ubuntu 24.04. Speculare a Start-BurnInTest.ps1 (Windows).
Prerequisiti:
- Repo burnin-dummy su Gitea con build.sh nella root del branch main
- Credenziali guest nel Credential Manager target 'BuildVMGuest'
(usate per il PAT Gitea; SSH usa chiave F:\CI\keys\ci_linux)
- Gitea PAT nel Credential Manager target 'GiteaPAT'
New-StoredCredential -Target 'GiteaPAT' -UserName 'ci' -Password '<pat>' -Persist LocalMachine
- Template LinuxBuild2404 con snapshot BaseClean-Linux in stato powered-off
- VMware Tools attivo nel guest (per auto-detect IP)
- build.sh eseguibile (chmod +x) nel repo — oppure viene richiamato via bash
Esempi di utilizzo:
# Run completo (3 round, 4 job paralleli)
.\Start-BurnInTest-Linux.ps1
# Smoke veloce (1 round, 2 job)
.\Start-BurnInTest-Linux.ps1 -Rounds 1 -Parallelism 2
# Build piu' lungo per osservare concorrenza prolungata
.\Start-BurnInTest-Linux.ps1 -Rounds 1 -BuildCommand 'sleep 120'
.PARAMETER Rounds
Numero di round sequenziali. Default: 3.
.PARAMETER Parallelism
Job concorrenti per round. Default: 4.
.PARAMETER BuildCommand
Comando da eseguire nel guest come passo build (eseguito via SSH).
Default: 'bash build.sh'
.PARAMETER RoundTimeoutMinutes
Minuti massimi di attesa per ogni round prima di interrompere i job rimasti.
Default: 60.
#>
param(
[int] $Rounds = 3,
[int] $Parallelism = 4,
[string] $BuildCommand = 'bash build.sh',
[int] $RoundTimeoutMinutes = 60
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$burnInParams = @{
RepoUrl = 'http://10.10.20.11:3100/Simone/burnin-dummy.git'
Branch = 'main'
TemplatePath = '/var/lib/ci/templates/LinuxBuild2404/LinuxBuild2404.vmx'
SnapshotName = 'BaseClean-Linux'
CloneBaseDir = '/var/lib/ci/build-vms/'
ArtifactBaseDir = '/var/lib/ci/artifacts/'
GuestCredentialTarget = 'BuildVMGuest'
GiteaCredentialTarget = 'GiteaPAT'
VmrunPath = '/usr/bin/vmrun'
GuestOS = 'Linux'
BuildCommand = $BuildCommand
Parallelism = $Parallelism
Rounds = $Rounds
RoundTimeoutMinutes = $RoundTimeoutMinutes
}
& (Join-Path $PSScriptRoot 'Test-CapacityBurnIn.ps1') @burnInParams
exit $LASTEXITCODE
+77
View File
@@ -0,0 +1,77 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Wrapper di lancio per Test-CapacityBurnIn.ps1 con i parametri del lab locale.
.DESCRIPTION
Esegue il burn-in a 4 job / 3 round contro il repo burnin-dummy su Gitea.
Tutti i parametri fissi del lab sono qui hardcoded; i parametri variabili
(Rounds, Parallelism, BuildCommand) possono essere sovrascritti da CLI.
Prerequisiti:
- Repo burnin-dummy creato su Gitea (anche vuoto con branch main)
- Credenziali guest nel Credential Manager target 'BuildVMGuest'
- Gitea PAT nel Credential Manager target 'GiteaPAT'
New-StoredCredential -Target 'GiteaPAT' -UserName 'ci' -Password '<pat>' -Persist LocalMachine
- Template WinBuild2025 con snapshot BaseClean in stato powered-off
- VMware Tools attivo nel guest (per auto-detect IP)
Esempi di utilizzo:
# Run completo (3 round, 4 job paralleli)
.\Start-BurnInTest.ps1
# Smoke veloce (1 round, 2 job)
.\Start-BurnInTest.ps1 -Rounds 1 -Parallelism 2
# Verifica parallelismo reale (build da 2 minuti, 1 round)
.\Start-BurnInTest.ps1 -Rounds 1 -BuildCommand 'Start-Sleep 120'
.PARAMETER Rounds
Numero di round sequenziali. Default: 3.
.PARAMETER Parallelism
Job concorrenti per round. Default: 4.
.PARAMETER BuildCommand
Comando da eseguire nel guest come passo build (eseguito via cmd /c nel guest).
Default: 'ping 127.0.0.1 -n 31 > nul' (attesa ~30s, nessun output reale).
Quando il build command non produce la directory dist, Invoke-RemoteBuild crea
automaticamente un artifact placeholder (build-ok.txt in zip) per permettere
all'intero pipeline di completarsi.
Usare 'ping 127.0.0.1 -n 601 > nul' per mantenere 4 VM attive ~600s
e osservare la concorrenza prolungata.
.PARAMETER RoundTimeoutMinutes
Minuti massimi di attesa per ogni round prima di interrompere i job rimasti.
Default: 60.
#>
param(
[int] $Rounds = 3,
[int] $Parallelism = 4,
[string] $BuildCommand = 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File build.ps1',
[int] $RoundTimeoutMinutes = 60
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$burnInParams = @{
RepoUrl = 'http://10.10.20.11:3100/Simone/burnin-dummy.git'
Branch = 'main'
TemplatePath = '/var/lib/ci/templates/WinBuild2025/WinBuild2025.vmx'
SnapshotName = 'BaseClean'
CloneBaseDir = '/var/lib/ci/build-vms/'
ArtifactBaseDir = '/var/lib/ci/artifacts/'
GuestCredentialTarget = 'BuildVMGuest'
GiteaCredentialTarget = 'GiteaPAT'
VmrunPath = '/usr/bin/vmrun'
GuestOS = 'Auto'
BuildCommand = $BuildCommand
Parallelism = $Parallelism
Rounds = $Rounds
RoundTimeoutMinutes = $RoundTimeoutMinutes
}
& (Join-Path $PSScriptRoot 'Test-CapacityBurnIn.ps1') @burnInParams
exit $LASTEXITCODE
+368
View File
@@ -0,0 +1,368 @@
#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 = '',
[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)
}
$argArray = $argList.ToArray()
# Start-Job runs a new PS session; call the platform PS executable as child
# process so exit $exitCode from Invoke-CIJob.ps1 is captured in $LASTEXITCODE.
# $IsWindows is evaluated inside the job worker (correct OS context).
# 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)
$psExe = if ($IsWindows) { 'powershell.exe' } else { 'pwsh' }
$out = & $psExe @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
+166
View File
@@ -0,0 +1,166 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Smoke test for the CI pipeline. Validates the full lifecycle end-to-end
using a trivial no-op build.
.DESCRIPTION
Invokes Invoke-CIJob.ps1 with a minimal build command (write a marker file)
and checks that the pipeline completes successfully:
- Invoke-CIJob.ps1 exits 0
- Artifact directory is created: F:\CI\Artifacts\<JobId>\
- invoke-ci.jsonl contains a job-success event
This script can be run directly on the CI host without act_runner.
It is also invoked by gitea/workflows/self-test.yml via the composite action.
.PARAMETER GuestOS
Guest OS template to use. Windows (default) or Linux.
.PARAMETER TemplatePath
Override the VMX template path. Defaults to the standard template for
the chosen GuestOS.
.PARAMETER SnapshotName
Snapshot to clone from. Default: BaseClean (Windows) or BaseClean-Linux (Linux).
.PARAMETER JobId
Job ID for this test run. Default: smoke-<timestamp>.
.PARAMETER VmrunPath
Path to vmrun.exe. Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
.EXAMPLE
# Test Windows template
.\Test-Smoke.ps1
.EXAMPLE
# Test Linux template
.\Test-Smoke.ps1 -GuestOS Linux
#>
[CmdletBinding()]
param(
[ValidateSet('Windows', 'Linux')]
[string] $GuestOS = 'Windows',
[string] $TemplatePath,
[string] $SnapshotName,
[string] $JobId = "smoke-$(Get-Date -Format 'yyyyMMdd-HHmmss')",
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe',
[string] $ArtifactBaseDir = 'F:\CI\Artifacts',
[string] $LogDir = 'F:\CI\Logs'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$scriptDir = $PSScriptRoot
# ── Resolve defaults ──────────────────────────────────────────────────────────
if (-not $TemplatePath) {
if ($GuestOS -eq 'Linux') {
$TemplatePath = if ($env:GITEA_CI_LINUX_TEMPLATE_PATH) {
$env:GITEA_CI_LINUX_TEMPLATE_PATH
} else {
'F:\CI\Templates\LinuxBuild2404\LinuxBuild2404.vmx'
}
} else {
$TemplatePath = if ($env:GITEA_CI_TEMPLATE_PATH) {
$env:GITEA_CI_TEMPLATE_PATH
} else {
'F:\CI\Templates\WinBuild2025\WinBuild2025.vmx'
}
}
}
if (-not $SnapshotName) {
$SnapshotName = if ($GuestOS -eq 'Linux') { 'BaseClean-Linux' } else { 'BaseClean' }
}
# ── Build command ─────────────────────────────────────────────────────────────
# Writes a marker file to the standard output directory for the chosen OS.
if ($GuestOS -eq 'Linux') {
$buildCommand = 'mkdir -p /opt/ci/output && echo smoke-ok > /opt/ci/output/smoke.txt'
$artifactSource = '/opt/ci/output'
} else {
$buildCommand = 'New-Item -ItemType Directory -Path C:\CI\output -Force | Out-Null; Set-Content C:\CI\output\smoke.txt "smoke-ok"'
$artifactSource = 'C:\CI\output'
}
Write-Host "============================================================"
Write-Host "[Test-Smoke] GuestOS : $GuestOS"
Write-Host "[Test-Smoke] Template : $TemplatePath"
Write-Host "[Test-Smoke] Snapshot : $SnapshotName"
Write-Host "[Test-Smoke] JobId : $JobId"
Write-Host "============================================================"
# ── Run the pipeline ──────────────────────────────────────────────────────────
$invokeParams = @{
JobId = $JobId
RepoUrl = 'https://example.invalid/smoke-test.git' # never cloned; UseGitClone omitted
Branch = 'main'
TemplatePath = $TemplatePath
SnapshotName = $SnapshotName
BuildCommand = $buildCommand
GuestArtifactSource = $artifactSource
ArtifactBaseDir = $ArtifactBaseDir
LogDir = $LogDir
GuestOS = $GuestOS
VmrunPath = $VmrunPath
}
$invokeScript = Join-Path $scriptDir 'Invoke-CIJob.ps1'
& $invokeScript @invokeParams
$pipelineExit = $LASTEXITCODE
# ── Assert artifacts ──────────────────────────────────────────────────────────
$artifactDir = Join-Path $ArtifactBaseDir $JobId
$jsonlPath = Join-Path $LogDir $JobId 'invoke-ci.jsonl'
Write-Host "`n[Test-Smoke] Verifying results..."
$pass = $true
if ($pipelineExit -ne 0) {
Write-Host "[Test-Smoke] FAIL: Invoke-CIJob.ps1 exited $pipelineExit" -ForegroundColor Red
$pass = $false
} else {
Write-Host "[Test-Smoke] OK: pipeline exit 0" -ForegroundColor Green
}
if (Test-Path $artifactDir) {
Write-Host "[Test-Smoke] OK: artifact dir exists: $artifactDir" -ForegroundColor Green
} else {
Write-Host "[Test-Smoke] FAIL: artifact dir not found: $artifactDir" -ForegroundColor Red
$pass = $false
}
if (Test-Path $jsonlPath) {
$jobSuccess = Get-Content $jsonlPath -Raw -ErrorAction SilentlyContinue |
Where-Object { $_ } |
ForEach-Object { $_ } |
Where-Object { $_ -match '"phase"\s*:\s*"job"' -and $_ -match '"status"\s*:\s*"success"' }
if ($jobSuccess) {
Write-Host "[Test-Smoke] OK: JSONL contains job/success event" -ForegroundColor Green
} else {
Write-Host "[Test-Smoke] FAIL: JSONL missing job/success event in: $jsonlPath" -ForegroundColor Red
$pass = $false
}
} else {
Write-Host "[Test-Smoke] FAIL: JSONL log not found: $jsonlPath" -ForegroundColor Red
$pass = $false
}
Write-Host ''
if ($pass) {
Write-Host "[Test-Smoke] PASSED — $GuestOS smoke test completed successfully." -ForegroundColor Green
exit 0
} else {
Write-Host "[Test-Smoke] FAILED — one or more checks did not pass." -ForegroundColor Red
exit 1
}
+190
View File
@@ -0,0 +1,190 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Validates host-side security prerequisites for the CI system.
.DESCRIPTION
Checks that:
1. The SSH private key for Linux guest access exists and has restrictive ACLs
(only the current user and SYSTEM/Administrators may read it).
2. The Windows Credential Manager target used by CI guest sessions is accessible.
3. The CI key directories exist with expected permissions.
Run after `Setup-Host.ps1` and any time credentials or keys are rotated.
Exits with code 0 on all-pass, non-zero if any check fails.
.PARAMETER SshKeyPath
Path to the SSH private key used for Linux guest access.
Default: F:\CI\keys\ci_linux
.PARAMETER GuestCredTarget
Windows Credential Manager target name for Windows guest credentials.
Default: BuildVMGuest
.PARAMETER CIRootDir
Root CI directory to verify existence.
Default: F:\CI
.EXAMPLE
.\Validate-HostState.ps1
.EXAMPLE
.\Validate-HostState.ps1 -SshKeyPath 'F:\CI\keys\ci_linux' -Verbose
#>
[CmdletBinding()]
param(
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
[string] $GuestCredTarget = 'BuildVMGuest',
[string] $CIRootDir = 'F:\CI'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ── Helpers ───────────────────────────────────────────────────────────────────
$script:passCount = 0
$script:failCount = 0
function Write-Check {
param([string]$Label, [scriptblock]$Test)
try {
$result = & $Test
if ($result) {
Write-Host " [OK] $Label"
$script:passCount++
} else {
Write-Host " [FAIL] $Label"
$script:failCount++
}
} catch {
Write-Host " [FAIL] $Label — $_"
$script:failCount++
}
}
# ── Check 1: SSH key file exists ──────────────────────────────────────────────
Write-Host "`n=== SSH Key ($SshKeyPath) ==="
Write-Check "SSH private key file exists" {
Test-Path $SshKeyPath -PathType Leaf
}
Write-Check "SSH public key file exists" {
Test-Path "${SshKeyPath}.pub" -PathType Leaf
}
# Check ACLs: only SYSTEM, Administrators, and the current user should have access
Write-Check "SSH key ACL — no unexpected identities" {
if (-not (Test-Path $SshKeyPath -PathType Leaf)) { return $false }
$acl = Get-Acl -Path $SshKeyPath
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
# Allowed identities (case-insensitive prefix matching)
$allowedPatterns = @(
'NT AUTHORITY\SYSTEM',
'BUILTIN\Administrators',
$currentUser
)
$unexpected = @()
foreach ($ace in $acl.Access) {
$identity = $ace.IdentityReference.Value
$matched = $false
foreach ($pattern in $allowedPatterns) {
if ($identity -ieq $pattern) { $matched = $true; break }
}
if (-not $matched) {
$unexpected += $identity
}
}
if ($unexpected.Count -gt 0) {
Write-Verbose "[ACL] Unexpected identities on SSH key: $($unexpected -join ', ')"
return $false
}
return $true
}
Write-Check "SSH key not world-readable (Everyone/Users absent)" {
if (-not (Test-Path $SshKeyPath -PathType Leaf)) { return $false }
$acl = Get-Acl -Path $SshKeyPath
$broad = $acl.Access | Where-Object {
$id = $_.IdentityReference.Value
$id -ilike '*Everyone*' -or $id -ilike 'BUILTIN\Users'
}
return ($null -eq $broad -or @($broad).Count -eq 0)
}
# ── Check 2: Credential Manager target ────────────────────────────────────────
Write-Host "`n=== Credential Manager (target: $GuestCredTarget) ==="
Write-Check "CredentialManager module available" {
$null -ne (Get-Module -Name CredentialManager -ListAvailable -ErrorAction SilentlyContinue)
}
Write-Check "Credential target '$GuestCredTarget' accessible" {
# Use cmdkey.exe as a fallback — avoids requiring CredentialManager module
$cmdkeyOut = & cmdkey.exe /list:$GuestCredTarget 2>&1 | Out-String
if ($cmdkeyOut -imatch [regex]::Escape($GuestCredTarget)) {
return $true
}
# Try via CredentialManager module if available
if ($null -ne (Get-Module -Name CredentialManager -ListAvailable -ErrorAction SilentlyContinue)) {
Import-Module CredentialManager -ErrorAction SilentlyContinue
$cred = Get-StoredCredential -Target $GuestCredTarget -ErrorAction SilentlyContinue
return ($null -ne $cred)
}
return $false
}
# ── Check 3: CI directory structure ───────────────────────────────────────────
Write-Host "`n=== CI Directory Structure ($CIRootDir) ==="
$requiredDirs = @(
'BuildVMs',
'Artifacts',
'Logs',
'State',
'keys',
'Cache\NuGet',
'Cache\pip',
'Templates'
)
foreach ($rel in $requiredDirs) {
$full = Join-Path $CIRootDir $rel
Write-Check "Directory exists: $rel" { Test-Path $full -PathType Container }
}
# ── Check 4: vmrun.exe reachable ──────────────────────────────────────────────
Write-Host "`n=== VMware ==="
Write-Check "vmrun.exe found in PATH or default location" {
$vmrunDefault = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
$inPath = $null -ne (Get-Command 'vmrun.exe' -ErrorAction SilentlyContinue)
$atDefault = Test-Path $vmrunDefault -PathType Leaf
$inPath -or $atDefault
}
# ── Summary ───────────────────────────────────────────────────────────────────
Write-Host "`n=== Summary ==="
Write-Host " Passed : $($script:passCount)"
Write-Host " Failed : $($script:failCount)"
if ($script:failCount -gt 0) {
Write-Host "`n[Validate-HostState] $($script:failCount) check(s) failed. Review output above." -ForegroundColor Red
exit 1
} else {
Write-Host "`n[Validate-HostState] All checks passed." -ForegroundColor Green
exit 0
}
+44
View File
@@ -0,0 +1,44 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI.
# Original PS implementation moved to git history; see plans/A2-closeout.md.
# PowerShell common params (-ErrorAction etc.) must not be forwarded
# to the Python CLI. Value-taking ones also consume the next token.
$commonWithValue = @{
erroraction = $true; warningaction = $true; informationaction = $true
progressaction = $true; errorvariable = $true; warningvariable = $true
informationvariable = $true; outvariable = $true; outbuffer = $true
pipelinevariable = $true
}
$commonSwitch = @{ verbose = $true; debug = $true; whatif = $true; confirm = $true }
$pyArgs = @()
for ($i = 0; $i -lt $args.Count; $i++) {
$a = $args[$i]
if ($a -is [string] -and $a.Length -gt 1 -and $a.StartsWith('-') -and -not $a.StartsWith('--')) {
$name = $a.Substring(1)
$lname = $name.ToLower()
if ($commonSwitch.ContainsKey($lname)) { continue }
if ($commonWithValue.ContainsKey($lname)) { $i++; continue }
$kebab = [Regex]::Replace($name, '(?<=[a-z0-9])([A-Z])', '-$1')
$kebab = [Regex]::Replace($kebab, '([A-Z]+)([A-Z][a-z])', '$1-$2')
$pyArgs += '--' + $kebab.ToLower()
}
else {
$pyArgs += $a
}
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator wait-ready @pyArgs
exit $LASTEXITCODE
+44
View File
@@ -0,0 +1,44 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI.
# Original PS implementation moved to git history; see plans/A2-closeout.md.
# PowerShell common params (-ErrorAction etc.) must not be forwarded
# to the Python CLI. Value-taking ones also consume the next token.
$commonWithValue = @{
erroraction = $true; warningaction = $true; informationaction = $true
progressaction = $true; errorvariable = $true; warningvariable = $true
informationvariable = $true; outvariable = $true; outbuffer = $true
pipelinevariable = $true
}
$commonSwitch = @{ verbose = $true; debug = $true; whatif = $true; confirm = $true }
$pyArgs = @()
for ($i = 0; $i -lt $args.Count; $i++) {
$a = $args[$i]
if ($a -is [string] -and $a.Length -gt 1 -and $a.StartsWith('-') -and -not $a.StartsWith('--')) {
$name = $a.Substring(1)
$lname = $name.ToLower()
if ($commonSwitch.ContainsKey($lname)) { continue }
if ($commonWithValue.ContainsKey($lname)) { $i++; continue }
$kebab = [Regex]::Replace($name, '(?<=[a-z0-9])([A-Z])', '-$1')
$kebab = [Regex]::Replace($kebab, '([A-Z]+)([A-Z][a-z])', '$1-$2')
$pyArgs += '--' + $kebab.ToLower()
}
else {
$pyArgs += $a
}
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator monitor disk @pyArgs
exit $LASTEXITCODE
+44
View File
@@ -0,0 +1,44 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Shim: delegates to the Python ci_orchestrator CLI.
# Original PS implementation moved to git history; see plans/A2-closeout.md.
# PowerShell common params (-ErrorAction etc.) must not be forwarded
# to the Python CLI. Value-taking ones also consume the next token.
$commonWithValue = @{
erroraction = $true; warningaction = $true; informationaction = $true
progressaction = $true; errorvariable = $true; warningvariable = $true
informationvariable = $true; outvariable = $true; outbuffer = $true
pipelinevariable = $true
}
$commonSwitch = @{ verbose = $true; debug = $true; whatif = $true; confirm = $true }
$pyArgs = @()
for ($i = 0; $i -lt $args.Count; $i++) {
$a = $args[$i]
if ($a -is [string] -and $a.Length -gt 1 -and $a.StartsWith('-') -and -not $a.StartsWith('--')) {
$name = $a.Substring(1)
$lname = $name.ToLower()
if ($commonSwitch.ContainsKey($lname)) { continue }
if ($commonWithValue.ContainsKey($lname)) { $i++; continue }
$kebab = [Regex]::Replace($name, '(?<=[a-z0-9])([A-Z])', '-$1')
$kebab = [Regex]::Replace($kebab, '([A-Z]+)([A-Z][a-z])', '$1-$2')
$pyArgs += '--' + $kebab.ToLower()
}
else {
$pyArgs += $a
}
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) {
# $IsWindows exists only on PS 6+; absent on PS 5.1 (always Windows)
if ($null -ne $IsWindows -and $IsWindows -eq $false) {
$venvPython = '/opt/ci/venv/bin/python'
} else {
$venvPython = 'F:\CI\python\venv\Scripts\python.exe'
}
}
& $venvPython -m ci_orchestrator monitor runner @pyArgs
exit $LASTEXITCODE