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:
+30
-195
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user