feat(a3): port build pipeline (vm new, build run, artifacts collect)

Implements Phase A3 of plans/implementation-plan-A-B.md:

- commands/vm.py: vm new (replaces New-BuildVM.ps1)

- commands/build.py: build run (replaces Invoke-RemoteBuild.ps1) with WinRM/SSH dispatch and stdout streaming for act_runner

- commands/artifacts.py: artifacts collect (replaces Get-BuildArtifacts.ps1) using transport.fetch()

- 3 PS scripts reduced to shims preserving \0

- Pester tests/{New-BuildVM,Wait-VMReady,Remove-BuildVM}.Tests.ps1 removed; equivalent cases covered in pytest

- Phase C hook: build/artifacts accept opaque VmHandle/vmx; no Windows path assumptions

Tests: 91 pytest, ruff clean, mypy --strict clean, coverage 78.27% (>=70 gate).

Master checklist + step A3 attivita + 'definizione di fatto' updated; A3-closeout.md added.
This commit is contained in:
2026-05-14 17:20:34 +02:00
parent 096ba7fe16
commit 816a15503e
15 changed files with 1900 additions and 1061 deletions
+30 -195
View File
@@ -1,45 +1,16 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Copies build artifacts from an ephemeral VM to the host artifact directory.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
.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.
# 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`).
.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('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
[string] $IPAddress,
[Parameter()]
@@ -53,175 +24,39 @@ param(
[switch] $IncludeLogs,
# Guest OS type — Linux uses SCP instead of WinRM.
[ValidateSet('Windows', 'Linux')]
[string] $GuestOS = 'Windows',
# SSH private key path (used when GuestOS=Linux).
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
# SSH username (used when GuestOS=Linux).
[string] $SshUser = 'ci_build',
# Persistent known_hosts file for SSH calls (CI jobs).
# Empty string (default) = permissive mode (StrictHostKeyChecking=no, UserKnownHostsFile=NUL).
[string] $SshKnownHostsFile = '',
# Optional job ID and commit SHA written into manifest.json.
# When either is non-empty a manifest.json is written to $HostArtifactDir
# listing all collected files with name, size, and SHA256.
[string] $JobId = '',
[string] $Commit = ''
[string] $JobId = '',
[string] $Commit = '',
[string] $CredentialTarget = ''
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$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
)
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
if ($SshKnownHostsFile) { $pyArgs += @('--ssh-known-hosts-file', $SshKnownHostsFile) }
if ($CredentialTarget) { $pyArgs += @('--credential-target', $CredentialTarget) }
if ($IncludeLogs.IsPresent) { $pyArgs += '--include-logs' }
# ── Helper: write artifact manifest ─────────────────────────────────────────
function Write-ArtifactManifest {
param([string] $Dir, [string] $JobId, [string] $Commit)
$files = @(Get-ChildItem -Path $Dir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne 'manifest.json' })
$fileEntries = foreach ($f in $files) {
[pscustomobject]@{
name = $f.FullName.Substring($Dir.TrimEnd('\', '/').Length).TrimStart('\', '/')
size = $f.Length
sha256 = (Get-FileHash $f.FullName -Algorithm SHA256).Hash.ToLower()
}
}
$manifest = [ordered]@{
ts = (Get-Date -Format 'o')
jobId = $JobId
commit = $Commit
files = @($fileEntries)
}
$manifestPath = Join-Path $Dir 'manifest.json'
try {
$manifest | ConvertTo-Json -Depth 4 |
Set-Content -Path $manifestPath -Encoding UTF8 -Force -ErrorAction Stop
Write-Host "[Get-BuildArtifacts] Manifest written: $manifestPath ($($files.Count) file(s))"
} catch {
Write-Warning "[Get-BuildArtifacts] Could not write manifest.json: $_"
}
if ($Credential) {
Write-Host '[Get-BuildArtifacts] -Credential ignored; Python CLI uses keyring (set -CredentialTarget to override target name).'
}
if ($GuestOS -eq 'Windows' -and $null -eq $Credential) {
throw "Credential is required when GuestOS is Windows."
}
# ── Linux branch: use scp to transfer artifacts ─────────────────────────────────────────────
if ($GuestOS -eq 'Linux') {
Import-Module (Join-Path $PSScriptRoot '_Transport.psm1') -Force
if (-not (Test-Path $HostArtifactDir)) {
New-Item -ItemType Directory -Path $HostArtifactDir -Force | Out-Null
Write-Host "[Get-BuildArtifacts] Created host artifact directory: $HostArtifactDir"
}
Write-Host "[Get-BuildArtifacts] Transferring artifacts via SCP from $GuestArtifactPath ..."
Copy-SshItem `
-Source "$GuestArtifactPath/." `
-Destination $HostArtifactDir `
-IP $IPAddress `
-User $SshUser `
-KeyPath $SshKeyPath `
-KnownHostsFile $SshKnownHostsFile `
-Direction 'FromGuest' `
-Recurse
$transferred = @(Get-ChildItem -Path $HostArtifactDir -Recurse -File -ErrorAction SilentlyContinue)
if ($transferred.Count -eq 0) {
throw "[Get-BuildArtifacts] No files found in $HostArtifactDir after SCP transfer."
}
Write-Host "[Get-BuildArtifacts] $($transferred.Count) file(s) collected from Linux guest."
if ($JobId -ne '' -or $Commit -ne '') {
Write-ArtifactManifest -Dir $HostArtifactDir -JobId $JobId -Commit $Commit
}
return
}
# 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-CISessionOption
Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..."
$session = $null
$attempts = 3
$delay = 10 # seconds between attempts
for ($i = 1; $i -le $attempts; $i++) {
try {
$session = New-PSSession `
-ComputerName $IPAddress `
-Credential $Credential `
-SessionOption $sessionOptions `
-UseSSL `
-Port 5986 `
-Authentication Basic `
-ErrorAction Stop
break
} catch {
if ($i -eq $attempts) { throw }
Write-Warning "[Get-BuildArtifacts] WinRM connect attempt $i/$attempts failed — retrying in ${delay}s... ($_)"
Start-Sleep -Seconds $delay
}
}
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' -Recurse -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"
}
$fileItem = Get-Item $hostDestPath
if ($fileItem.Length -eq 0) {
throw "Artifact file exists but is empty: $hostDestPath"
}
$sizeKB = [math]::Round($fileItem.Length / 1KB, 1)
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
if ($JobId -ne '' -or $Commit -ne '') {
Write-ArtifactManifest -Dir $HostArtifactDir -JobId $JobId -Commit $Commit
}
return $hostDestPath
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
}
$venvPython = $env:CI_VENV_PYTHON
if (-not $venvPython) { $venvPython = 'F:\CI\python\venv\Scripts\python.exe' }
& $venvPython -m ci_orchestrator artifacts collect @pyArgs
exit $LASTEXITCODE