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:
@@ -0,0 +1,31 @@
|
|||||||
|
# A3 — Closeout
|
||||||
|
|
||||||
|
Fase A3 di `plans/implementation-plan-A-B.md`: pipeline core (clone → build → collect) in Python.
|
||||||
|
Branch: `feature/python-rewrite-phase-a`.
|
||||||
|
|
||||||
|
## Stato attuale
|
||||||
|
|
||||||
|
- [x] `commands/vm.py` esteso con `vm new` (sostituisce `New-BuildVM.ps1`)
|
||||||
|
- [x] `commands/build.py` con `build run` (sostituisce `Invoke-RemoteBuild.ps1`)
|
||||||
|
- [x] `commands/artifacts.py` con `artifacts collect` (sostituisce `Get-BuildArtifacts.ps1`)
|
||||||
|
- [x] Tutti i 3 `.ps1` ridotti a shim verso la CLI Python
|
||||||
|
- [x] Pester `tests/{New-BuildVM,Wait-VMReady,Remove-BuildVM}.Tests.ps1` rimossi (casi negativi coperti dai pytest equivalenti)
|
||||||
|
- [x] 91 pytest, ruff/mypy --strict clean, coverage 78.27%
|
||||||
|
- [x] Hook Fase C: `build run` + `artifacts collect` accettano `VmHandle`/`vmx` opaco; nessuna assunzione path Windows
|
||||||
|
- [ ] Smoke end-to-end manuale: clone WinBuild2025 → build script trivial → collect artifact ZIP
|
||||||
|
- [ ] Smoke end-to-end Linux equivalente
|
||||||
|
|
||||||
|
## Definizione di "fatto" A3
|
||||||
|
|
||||||
|
| Criterio | Stato |
|
||||||
|
| --- | --- |
|
||||||
|
| Pipeline build completa via Python CLI | ✅ |
|
||||||
|
| Shim PS preservano API per caller esistenti | ✅ |
|
||||||
|
| Smoke build PASS contro VM reale Windows e Linux | ⏳ validazione hardware |
|
||||||
|
| Pester `New-BuildVM.Tests.ps1` rimosso e sostituito | ✅ |
|
||||||
|
|
||||||
|
## Cosa resta a carico utente
|
||||||
|
|
||||||
|
1. Smoke manuale end-to-end Windows: `python -m ci_orchestrator vm new ... && build run ... && artifacts collect ...`
|
||||||
|
2. Stesso smoke su Linux template
|
||||||
|
3. Verificare che `Test-NsinnounpBuild.ps1` continui a passare invocando gli shim
|
||||||
@@ -60,10 +60,10 @@ A4; cambiano solo path/env vars in Fase B).
|
|||||||
- [x] [A2] Portare `Watch-RunnerHealth.ps1` → `monitor runner`
|
- [x] [A2] Portare `Watch-RunnerHealth.ps1` → `monitor runner`
|
||||||
- [x] [A2] Portare `Get-CIJobSummary.ps1` → `report job`
|
- [x] [A2] Portare `Get-CIJobSummary.ps1` → `report job`
|
||||||
- [x] [A2] Sostituire ognuno dei `.ps1` portati con shim a 3 righe verso la CLI Python
|
- [x] [A2] Sostituire ognuno dei `.ps1` portati con shim a 3 righe verso la CLI Python
|
||||||
- [ ] [A3] Portare `New-BuildVM.ps1` → `vm new`
|
- [x] [A3] Portare `New-BuildVM.ps1` → `vm new`
|
||||||
- [ ] [A3] Portare `Invoke-RemoteBuild.ps1` → `build run`
|
- [x] [A3] Portare `Invoke-RemoteBuild.ps1` → `build run`
|
||||||
- [ ] [A3] Portare `Get-BuildArtifacts.ps1` → `artifacts collect`
|
- [x] [A3] Portare `Get-BuildArtifacts.ps1` → `artifacts collect`
|
||||||
- [ ] [A3] Convertire test Pester `New-BuildVM.Tests.ps1`, `Wait-VMReady.Tests.ps1`, `Remove-BuildVM.Tests.ps1` in pytest
|
- [x] [A3] Convertire test Pester `New-BuildVM.Tests.ps1`, `Wait-VMReady.Tests.ps1`, `Remove-BuildVM.Tests.ps1` in pytest
|
||||||
- [ ] [A4] Portare `Invoke-CIJob.ps1` → `python -m ci_orchestrator job`
|
- [ ] [A4] Portare `Invoke-CIJob.ps1` → `python -m ci_orchestrator job`
|
||||||
- [ ] [A4] Aggiornare `gitea/actions/local-ci-build/action.yml` per invocare la CLI Python direttamente
|
- [ ] [A4] Aggiornare `gitea/actions/local-ci-build/action.yml` per invocare la CLI Python direttamente
|
||||||
- [ ] [A4] Forzare `PYTHONIOENCODING=utf-8` in `runner/config.yaml`
|
- [ ] [A4] Forzare `PYTHONIOENCODING=utf-8` in `runner/config.yaml`
|
||||||
@@ -229,15 +229,15 @@ Python preservando il comportamento dei `.ps1` esistenti.
|
|||||||
|
|
||||||
**Attività**:
|
**Attività**:
|
||||||
|
|
||||||
- [ ] Implementare `vm new` in `commands/vm.py` (parametri `--template`, `--snapshot`, `--name`, `--guest-os`)
|
- [x] Implementare `vm new` in `commands/vm.py` (parametri `--template`, `--snapshot`, `--name`, `--guest-os`)
|
||||||
- [ ] Integrare `WorkstationVmrunBackend.clone_linked` + `start` + attesa IP via `get_ip`
|
- [x] Integrare `WorkstationVmrunBackend.clone_linked` + `start` + attesa IP via `get_ip`
|
||||||
- [ ] Implementare `commands/build.py` con `build run` (parametri `--vmx`, `--script`, `--workdir`, `--guest-os`); usa `transport.winrm` per Windows, `transport.ssh` per Linux
|
- [x] Implementare `commands/build.py` con `build run` (parametri `--vmx`, `--script`, `--workdir`, `--guest-os`); usa `transport.winrm` per Windows, `transport.ssh` per Linux
|
||||||
- [ ] Gestione streaming output build verso stdout (act_runner cattura stdout — preservare comportamento `Write-Host`)
|
- [x] Gestione streaming output build verso stdout (act_runner cattura stdout — preservare comportamento `Write-Host`)
|
||||||
- [ ] Implementare `commands/artifacts.py` con `artifacts collect` (parametri `--vmx`, `--remote-path`, `--local-dir`)
|
- [x] Implementare `commands/artifacts.py` con `artifacts collect` (parametri `--vmx`, `--remote-path`, `--local-dir`)
|
||||||
- [ ] Sostituire `scripts/New-BuildVM.ps1`, `Invoke-RemoteBuild.ps1`, `Get-BuildArtifacts.ps1` con shim
|
- [x] Sostituire `scripts/New-BuildVM.ps1`, `Invoke-RemoteBuild.ps1`, `Get-BuildArtifacts.ps1` con shim
|
||||||
- [ ] Convertire `tests/New-BuildVM.Tests.ps1` in `tests/test_commands_vm_new.py`
|
- [x] Convertire `tests/New-BuildVM.Tests.ps1` in `tests/test_commands_vm_new.py`
|
||||||
- [ ] Aggiungere test pytest per `build run` (mock transport + capture stdout)
|
- [x] Aggiungere test pytest per `build run` (mock transport + capture stdout)
|
||||||
- [ ] Aggiungere test pytest per `artifacts collect` (mock SFTP/WinRM file copy + tmp_path)
|
- [x] Aggiungere test pytest per `artifacts collect` (mock SFTP/WinRM file copy + tmp_path)
|
||||||
- [ ] Validare end-to-end: clone WinBuild2025 → build script PowerShell trivial → collect artifact ZIP
|
- [ ] Validare end-to-end: clone WinBuild2025 → build script PowerShell trivial → collect artifact ZIP
|
||||||
|
|
||||||
**Hook futuri Fase C**: `build run` deve ricevere un `VmHandle` opaco,
|
**Hook futuri Fase C**: `build run` deve ricevere un `VmHandle` opaco,
|
||||||
@@ -257,10 +257,10 @@ Python; nessuna modifica ai workflow YAML in A3 (solo shim).
|
|||||||
|
|
||||||
**Definizione di fatto step A3**:
|
**Definizione di fatto step A3**:
|
||||||
|
|
||||||
- [ ] Pipeline build completa accessibile via Python CLI
|
- [x] Pipeline build completa accessibile via Python CLI
|
||||||
- [ ] Shim PS preservano l'API per i caller esistenti
|
- [x] Shim PS preservano l'API per i caller esistenti
|
||||||
- [ ] Smoke build PASS contro VM reale Windows e Linux
|
- [ ] Smoke build PASS contro VM reale Windows e Linux
|
||||||
- [ ] Pester `New-BuildVM.Tests.ps1` rimosso e sostituito
|
- [x] Pester `New-BuildVM.Tests.ps1` rimosso e sostituito
|
||||||
|
|
||||||
### A4 — Orchestratore + switch workflow
|
### A4 — Orchestratore + switch workflow
|
||||||
|
|
||||||
|
|||||||
+30
-195
@@ -1,45 +1,16 @@
|
|||||||
#Requires -Version 5.1
|
#Requires -Version 5.1
|
||||||
<#
|
Set-StrictMode -Version Latest
|
||||||
.SYNOPSIS
|
$ErrorActionPreference = 'Stop'
|
||||||
Copies build artifacts from an ephemeral VM to the host artifact directory.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
# Shim: delegates to the Python ci_orchestrator CLI (`artifacts collect`).
|
||||||
Opens a WinRM session to the build VM and uses Copy-Item -FromSession
|
# Original PS implementation moved to git history; see plans/A3-closeout.md.
|
||||||
to transfer the artifact archive and optional log files.
|
#
|
||||||
Validates that at least one file was successfully transferred.
|
# The bound `-Credential` is intentionally discarded — Python uses keyring
|
||||||
Throws on failure so the caller's try/finally can clean up the VM.
|
# 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()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[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,
|
[string] $IPAddress,
|
||||||
|
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
@@ -53,175 +24,39 @@ param(
|
|||||||
|
|
||||||
[switch] $IncludeLogs,
|
[switch] $IncludeLogs,
|
||||||
|
|
||||||
# Guest OS type — Linux uses SCP instead of WinRM.
|
|
||||||
[ValidateSet('Windows', 'Linux')]
|
[ValidateSet('Windows', 'Linux')]
|
||||||
[string] $GuestOS = 'Windows',
|
[string] $GuestOS = 'Windows',
|
||||||
|
|
||||||
# SSH private key path (used when GuestOS=Linux).
|
|
||||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||||
|
|
||||||
# SSH username (used when GuestOS=Linux).
|
|
||||||
[string] $SshUser = 'ci_build',
|
[string] $SshUser = 'ci_build',
|
||||||
|
|
||||||
# Persistent known_hosts file for SSH calls (CI jobs).
|
|
||||||
# Empty string (default) = permissive mode (StrictHostKeyChecking=no, UserKnownHostsFile=NUL).
|
|
||||||
[string] $SshKnownHostsFile = '',
|
[string] $SshKnownHostsFile = '',
|
||||||
|
|
||||||
# Optional job ID and commit SHA written into manifest.json.
|
[string] $JobId = '',
|
||||||
# When either is non-empty a manifest.json is written to $HostArtifactDir
|
[string] $Commit = '',
|
||||||
# listing all collected files with name, size, and SHA256.
|
|
||||||
[string] $JobId = '',
|
[string] $CredentialTarget = ''
|
||||||
[string] $Commit = ''
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
$pyArgs = @(
|
||||||
$ErrorActionPreference = 'Stop'
|
'--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 ─────────────────────────────────────────
|
if ($Credential) {
|
||||||
function Write-ArtifactManifest {
|
Write-Host '[Get-BuildArtifacts] -Credential ignored; Python CLI uses keyring (set -CredentialTarget to override target name).'
|
||||||
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 ($GuestOS -eq 'Windows' -and $null -eq $Credential) {
|
$venvPython = $env:CI_VENV_PYTHON
|
||||||
throw "Credential is required when GuestOS is Windows."
|
if (-not $venvPython) { $venvPython = 'F:\CI\python\venv\Scripts\python.exe' }
|
||||||
}
|
& $venvPython -m ci_orchestrator artifacts collect @pyArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
# ── 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
|
|
||||||
}
|
|
||||||
|
|||||||
+48
-500
@@ -1,543 +1,91 @@
|
|||||||
#Requires -Version 5.1
|
#Requires -Version 5.1
|
||||||
<#
|
Set-StrictMode -Version Latest
|
||||||
.SYNOPSIS
|
$ErrorActionPreference = 'Stop'
|
||||||
Executes a build inside a guest VM (via WinRM). Supports two clone modes:
|
|
||||||
(1) Host-side clone: copy pre-cloned source via WinRM zip, or
|
|
||||||
(2) Guest-side clone: clone repo directly in VM via git (§3.3, requires Git)
|
|
||||||
|
|
||||||
.DESCRIPTION
|
# Shim: delegates to the Python ci_orchestrator CLI (`build run`).
|
||||||
Called by Invoke-CIJob.ps1 to orchestrate the build inside the VM.
|
# 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).
|
||||||
|
|
||||||
**Mode 1: Host-side (default)** — Caller provides pre-cloned source:
|
|
||||||
1. Compresses the source tree on host, copies archive into VM via WinRM
|
|
||||||
2. Runs dotnet restore + dotnet build inside VM
|
|
||||||
3. Packages output as C:\CI\output\artifacts.zip
|
|
||||||
|
|
||||||
**Mode 2: Guest-side (§3.3, -UseGitClone in Invoke-CIJob)** — Repo cloned in VM:
|
|
||||||
1. Clone repo directly into VM via git (eliminates host-zip-transfer overhead)
|
|
||||||
2. Runs dotnet restore + dotnet build inside VM
|
|
||||||
3. Packages output same as Mode 1
|
|
||||||
|
|
||||||
Supply either -HostSourceDir (Mode 1) or -CloneUrl (Mode 2), not both.
|
|
||||||
|
|
||||||
In Mode 2, PAT for private repos is read from Credential Manager inside
|
|
||||||
this script's WinRM session (§1.5: no argv, no log, cleanup at end).
|
|
||||||
|
|
||||||
.PARAMETER IPAddress
|
|
||||||
IP of the build VM (e.g. 192.168.11.101).
|
|
||||||
|
|
||||||
.PARAMETER Credential
|
|
||||||
PSCredential for the build user inside the VM.
|
|
||||||
Retrieve from Windows Credential Manager in production:
|
|
||||||
$cred = Get-StoredCredential -Target 'BuildVMGuest'
|
|
||||||
|
|
||||||
.PARAMETER HostSourceDir
|
|
||||||
[Mode 1] Full path on HOST to already-cloned repository (host-side clone mode).
|
|
||||||
Mutually exclusive with -CloneUrl. When omitted, -CloneUrl must be provided.
|
|
||||||
Example: C:\Users\user\AppData\Local\Temp\ci-src-run-42
|
|
||||||
|
|
||||||
.PARAMETER CloneUrl
|
|
||||||
[Mode 2, §3.3] Git URL to clone directly in VM (guest-side clone mode).
|
|
||||||
Mutually exclusive with -HostSourceDir. When provided, repo cloned via git inside VM.
|
|
||||||
PAT (if needed) read from Credential Manager at runtime.
|
|
||||||
Example: https://gitea.emulab.it/Simone/myrepo.git
|
|
||||||
|
|
||||||
.PARAMETER CloneBranch
|
|
||||||
[Mode 2] Branch to clone. Ignored in Mode 1. Default: main
|
|
||||||
|
|
||||||
.PARAMETER CloneCommit
|
|
||||||
[Mode 2] Specific commit SHA to checkout after clone. Ignored in Mode 1.
|
|
||||||
|
|
||||||
.PARAMETER CloneSubmodules
|
|
||||||
[Mode 2] Pass --recurse-submodules to git clone. Ignored in Mode 1.
|
|
||||||
|
|
||||||
.PARAMETER Configuration
|
|
||||||
MSBuild/dotnet build configuration. Default: Release
|
|
||||||
|
|
||||||
.PARAMETER GuestWorkDir
|
|
||||||
Working directory inside the VM where source will be placed.
|
|
||||||
Default: C:\CI\build
|
|
||||||
|
|
||||||
.PARAMETER GuestArtifactZip
|
|
||||||
Full path inside the VM where the artifact archive will be written.
|
|
||||||
Default: C:\CI\output\artifacts.zip
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
$cred = Get-Credential
|
|
||||||
.\Invoke-RemoteBuild.ps1 `
|
|
||||||
-IPAddress "192.168.11.101" `
|
|
||||||
-Credential $cred `
|
|
||||||
-HostSourceDir "C:\Temp\ci-src-run-42"
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[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,
|
[string] $IPAddress,
|
||||||
|
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[System.Management.Automation.PSCredential] $Credential,
|
[System.Management.Automation.PSCredential] $Credential,
|
||||||
|
|
||||||
# [Mode 1] Host-side clone — pre-cloned source directory on HOST
|
|
||||||
[ValidateScript({
|
|
||||||
if ($_ -and -not (Test-Path $_ -PathType Container)) {
|
|
||||||
throw "HostSourceDir path does not exist: $_"
|
|
||||||
}
|
|
||||||
return $true
|
|
||||||
})]
|
|
||||||
[string] $HostSourceDir = '',
|
[string] $HostSourceDir = '',
|
||||||
|
|
||||||
# [Mode 2, §3.3] Guest-side clone — Git URL for in-VM clone
|
|
||||||
[string] $CloneUrl = '',
|
[string] $CloneUrl = '',
|
||||||
|
|
||||||
# [Mode 2] Branch to clone (default: main). Ignored if HostSourceDir provided.
|
|
||||||
[string] $CloneBranch = 'main',
|
[string] $CloneBranch = 'main',
|
||||||
|
|
||||||
# [Mode 2] Specific commit SHA to checkout. Ignored if HostSourceDir provided.
|
|
||||||
[string] $CloneCommit = '',
|
[string] $CloneCommit = '',
|
||||||
|
|
||||||
# [Mode 2] Clone with --recurse-submodules. Ignored if HostSourceDir provided.
|
|
||||||
[switch] $CloneSubmodules,
|
[switch] $CloneSubmodules,
|
||||||
|
|
||||||
# [Mode 2] Credential Manager target for Gitea PAT (private repos).
|
|
||||||
# Store with: New-StoredCredential -Target 'GiteaPAT' -UserName '<user>' -Password '<pat>' -Persist LocalMachine
|
|
||||||
# Leave empty for public repos (no auth needed).
|
|
||||||
[string] $GiteaCredentialTarget = 'GiteaPAT',
|
[string] $GiteaCredentialTarget = 'GiteaPAT',
|
||||||
|
|
||||||
[string] $Configuration = 'Release',
|
[string] $Configuration = 'Release',
|
||||||
|
|
||||||
# Custom build command to run inside the VM (working dir = GuestWorkDir).
|
|
||||||
# When empty, defaults to: dotnet restore + dotnet build --configuration $Configuration
|
|
||||||
# Example: 'python build_plugin.py --final'
|
|
||||||
[string] $BuildCommand = '',
|
[string] $BuildCommand = '',
|
||||||
|
|
||||||
# Glob pattern (relative to GuestWorkDir) of files to include in the artifact zip.
|
|
||||||
# Used only when BuildCommand is set. Default: collect everything under 'dist'.
|
|
||||||
# Example: 'dist\**' or 'Contrib\nsInnoUnp\Build\**'
|
|
||||||
[string] $GuestArtifactSource = 'dist',
|
[string] $GuestArtifactSource = 'dist',
|
||||||
|
|
||||||
[string] $GuestWorkDir = 'C:\CI\build',
|
[string] $GuestWorkDir = 'C:\CI\build',
|
||||||
|
|
||||||
[string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip',
|
[string] $GuestArtifactZip = 'C:\CI\output\artifacts.zip',
|
||||||
|
|
||||||
# Redirect dotnet NuGet package store and pip download cache to VMware shared
|
|
||||||
# folders on the host (F:\CI\Cache\NuGet and F:\CI\Cache\pip).
|
|
||||||
# Requires the template VMX to have been configured by Set-TemplateSharedFolders.ps1
|
|
||||||
# and VMware Tools HGFS driver present in the guest.
|
|
||||||
[switch] $UseSharedCache,
|
[switch] $UseSharedCache,
|
||||||
|
|
||||||
# Skip artifact packaging entirely. Use for jobs that produce no output.
|
|
||||||
# When absent, missing GuestArtifactSource after a successful build is an error.
|
|
||||||
[switch] $SkipArtifact,
|
[switch] $SkipArtifact,
|
||||||
|
|
||||||
# Guest OS type — Linux uses SSH instead of WinRM.
|
|
||||||
[ValidateSet('Windows', 'Linux')]
|
[ValidateSet('Windows', 'Linux')]
|
||||||
[string] $GuestOS = 'Windows',
|
[string] $GuestOS = 'Windows',
|
||||||
|
|
||||||
# SSH private key path (used when GuestOS=Linux).
|
|
||||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||||
|
|
||||||
# SSH username (used when GuestOS=Linux).
|
|
||||||
[string] $SshUser = 'ci_build',
|
[string] $SshUser = 'ci_build',
|
||||||
|
|
||||||
# Persistent known_hosts file for SSH calls (CI jobs).
|
|
||||||
# Empty string (default) = permissive mode (StrictHostKeyChecking=no, UserKnownHostsFile=NUL).
|
|
||||||
[string] $SshKnownHostsFile = '',
|
[string] $SshKnownHostsFile = '',
|
||||||
|
|
||||||
# Work directory inside the Linux guest.
|
|
||||||
[string] $GuestLinuxWorkDir = '/opt/ci/build',
|
[string] $GuestLinuxWorkDir = '/opt/ci/build',
|
||||||
|
|
||||||
# Output directory inside the Linux guest.
|
|
||||||
[string] $GuestLinuxOutputDir = '/opt/ci/output',
|
[string] $GuestLinuxOutputDir = '/opt/ci/output',
|
||||||
|
|
||||||
# Extra environment variables injected into the guest before the build command (§6.5).
|
[hashtable] $ExtraGuestEnv = @{},
|
||||||
# Keys must be valid env var names. Values are plain strings (never logged).
|
|
||||||
[hashtable] $ExtraGuestEnv = @{}
|
# Optional override for the Credential Manager target (overrides any
|
||||||
|
# fallback resolved by Python). Discards $Credential.
|
||||||
|
[string] $CredentialTarget = ''
|
||||||
)
|
)
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
$pyArgs = @(
|
||||||
$ErrorActionPreference = 'Stop'
|
'--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
|
||||||
|
)
|
||||||
|
|
||||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
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) }
|
||||||
|
|
||||||
# ── Validate mutually exclusive modes ─────────────────────────────────────
|
if ($CloneSubmodules.IsPresent) { $pyArgs += '--clone-submodules' }
|
||||||
$hostCloneMode = -not [string]::IsNullOrWhiteSpace($HostSourceDir)
|
if ($UseSharedCache.IsPresent) { $pyArgs += '--use-shared-cache' }
|
||||||
$guestCloneMode = -not [string]::IsNullOrWhiteSpace($CloneUrl)
|
if ($SkipArtifact.IsPresent) { $pyArgs += '--skip-artifact' }
|
||||||
if ($hostCloneMode -and $guestCloneMode) {
|
|
||||||
throw "Provide either -HostSourceDir (host-side clone) or -CloneUrl (guest-side clone), not both."
|
|
||||||
}
|
|
||||||
if (-not $hostCloneMode -and -not $guestCloneMode) {
|
|
||||||
throw "Provide either -HostSourceDir (host-side clone) or -CloneUrl (guest-side clone)."
|
|
||||||
}
|
|
||||||
if ($guestCloneMode -and -not $CloneBranch) {
|
|
||||||
throw "CloneBranch is required when using -CloneUrl mode."
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($GuestOS -eq 'Windows' -and $null -eq $Credential) {
|
if ($ExtraGuestEnv -and $ExtraGuestEnv.Count -gt 0) {
|
||||||
throw "Credential is required when GuestOS is Windows."
|
foreach ($key in $ExtraGuestEnv.Keys) {
|
||||||
}
|
$pyArgs += @('--extra-env', "$key=$($ExtraGuestEnv[$key])")
|
||||||
|
|
||||||
if ($GuestOS -eq 'Linux') {
|
|
||||||
Import-Module (Join-Path $PSScriptRoot '_Transport.psm1') -Force
|
|
||||||
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Linux build mode — SSH transport"
|
|
||||||
|
|
||||||
# Step 1: Clean work dir (parent permissions handled by template setup)
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile `
|
|
||||||
-Command "rm -rf '$GuestLinuxWorkDir' && mkdir -p '$GuestLinuxWorkDir'"
|
|
||||||
|
|
||||||
if ($hostCloneMode) {
|
|
||||||
# ── Mode 1 (Linux): tar host source -> scp -> untar in guest ──
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Packaging host source ($HostSourceDir) ..."
|
|
||||||
$tarName = "ci-src-$([Guid]::NewGuid().ToString('N').Substring(0,8)).tar.gz"
|
|
||||||
$hostTar = Join-Path $env:TEMP $tarName
|
|
||||||
$guestTar = "/tmp/$tarName"
|
|
||||||
if (Test-Path $hostTar) { Remove-Item $hostTar -Force }
|
|
||||||
# bsdtar on Windows 10/11 supports -C and gz natively.
|
|
||||||
& tar -czf $hostTar -C $HostSourceDir .
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "[Invoke-RemoteBuild] tar failed packaging $HostSourceDir (exit $LASTEXITCODE)"
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Copying source archive to guest:$guestTar ..."
|
|
||||||
Copy-SshItem -Source $hostTar -Destination $guestTar `
|
|
||||||
-IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile -Direction ToGuest
|
|
||||||
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Extracting source in guest ($GuestLinuxWorkDir) ..."
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile `
|
|
||||||
-Command "tar -xzf '$guestTar' -C '$GuestLinuxWorkDir' && rm -f '$guestTar'"
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
if (Test-Path $hostTar) { Remove-Item $hostTar -Force -ErrorAction SilentlyContinue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# ── Mode 2 (Linux): in-guest git clone ──
|
|
||||||
$cloneCmd = "git clone --depth 1 --branch '$CloneBranch'"
|
|
||||||
if ($CloneSubmodules) { $cloneCmd += ' --recurse-submodules' }
|
|
||||||
|
|
||||||
$cloneCmd += " '$CloneUrl' '$GuestLinuxWorkDir'" # non-authed URL for logging
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Cloning $CloneUrl into guest $GuestLinuxWorkDir ..."
|
|
||||||
|
|
||||||
# PAT injection via git http.extraHeader — PAT never appears in argv or /proc/environ
|
|
||||||
$authHeader = ''
|
|
||||||
if ($GiteaCredentialTarget -ne '') {
|
|
||||||
try {
|
|
||||||
Import-Module CredentialManager -ErrorAction Stop
|
|
||||||
$credObj = Get-StoredCredential -Target $GiteaCredentialTarget -ErrorAction Stop
|
|
||||||
if ($credObj) {
|
|
||||||
$b64 = [Convert]::ToBase64String(
|
|
||||||
[System.Text.Encoding]::UTF8.GetBytes(
|
|
||||||
"$($credObj.UserName):$($credObj.GetNetworkCredential().Password)"))
|
|
||||||
$authHeader = "Authorization: Basic $b64"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Warning "[Invoke-RemoteBuild] Could not load PAT from Credential Manager ($GiteaCredentialTarget) — cloning without auth (public repo?)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($authHeader -ne '') {
|
|
||||||
$authCloneCmd = "git -c credential.helper= -c 'http.extraHeader=$authHeader' clone --depth 1 --branch '$CloneBranch'"
|
|
||||||
if ($CloneSubmodules) { $authCloneCmd += ' --recurse-submodules' }
|
|
||||||
$authCloneCmd += " '$CloneUrl' '$GuestLinuxWorkDir'"
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile -Command $authCloneCmd
|
|
||||||
} else {
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile -Command $cloneCmd
|
|
||||||
}
|
|
||||||
|
|
||||||
# Checkout specific commit if requested
|
|
||||||
if ($CloneCommit -ne '') {
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile `
|
|
||||||
-Command "git -C '$GuestLinuxWorkDir' checkout '$CloneCommit'"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Step 3: Run build command (always cd into workdir first)
|
|
||||||
# Prepend extra env exports (§6.5) — scoped to this SSH command only
|
|
||||||
$envPrefix = ''
|
|
||||||
foreach ($envKey in $ExtraGuestEnv.Keys) {
|
|
||||||
$envVal = $ExtraGuestEnv[$envKey] -replace "'", "'\''" # escape ' for POSIX single-quoted string
|
|
||||||
$envPrefix += "export $envKey='$envVal'; "
|
|
||||||
}
|
|
||||||
if ($BuildCommand -ne '') {
|
|
||||||
$buildCmd = "${envPrefix}cd '$GuestLinuxWorkDir' && $BuildCommand"
|
|
||||||
} else {
|
|
||||||
$buildCmd = "${envPrefix}cd '$GuestLinuxWorkDir' && make"
|
|
||||||
}
|
|
||||||
$displayCmd = if ($BuildCommand -ne '') { $BuildCommand } else { 'make' }
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Running build (env vars redacted): cd '$GuestLinuxWorkDir' && $displayCmd"
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile -Command $buildCmd
|
|
||||||
|
|
||||||
# Step 4: Stage artifact tree under $GuestLinuxOutputDir for SCP collection
|
|
||||||
$outDir = $GuestLinuxOutputDir
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile `
|
|
||||||
-Command "rm -rf '$outDir' && mkdir -p '$outDir'"
|
|
||||||
|
|
||||||
$artSrc = if ($GuestArtifactSource -ne '') { $GuestArtifactSource } else { 'dist' }
|
|
||||||
Invoke-SshCommand -IP $IPAddress -User $SshUser -KeyPath $SshKeyPath `
|
|
||||||
-KnownHostsFile $SshKnownHostsFile `
|
|
||||||
-Command "if [ -d '$GuestLinuxWorkDir/$artSrc' ]; then cp -r '$GuestLinuxWorkDir/$artSrc/.' '$outDir/'; elif [ -e '$GuestLinuxWorkDir/$artSrc' ]; then cp '$GuestLinuxWorkDir/$artSrc' '$outDir/'; else echo '[Invoke-RemoteBuild] WARNING: artifact source not found: $artSrc' >&2; fi"
|
|
||||||
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Linux build complete."
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
function Compress-BuildArtifact {
|
|
||||||
param(
|
|
||||||
[Parameter(Mandatory)]
|
|
||||||
[string] $Path,
|
|
||||||
[Parameter(Mandatory)]
|
|
||||||
[string] $DestinationPath
|
|
||||||
)
|
|
||||||
$7zip = 'C:\Program Files\7-Zip\7z.exe'
|
|
||||||
if (Test-Path $7zip) {
|
|
||||||
Write-Host "[Compress-BuildArtifact] Using 7-Zip (multi-threaded)..."
|
|
||||||
& $7zip a -mmt=on -mx1 $DestinationPath $Path 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ }
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "7-Zip compression failed (exit $LASTEXITCODE)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Host "[Compress-BuildArtifact] 7-Zip not found, using Compress-Archive (fallback)..."
|
|
||||||
Compress-Archive -Path $Path -DestinationPath $DestinationPath -CompressionLevel Fastest -Force
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$sessionOptions = New-CISessionOption
|
if ($Credential) {
|
||||||
|
Write-Host '[Invoke-RemoteBuild] -Credential ignored; Python CLI uses keyring (set -CredentialTarget to override target name).'
|
||||||
Write-Host "[Invoke-RemoteBuild] Connecting to VM at $IPAddress..."
|
|
||||||
|
|
||||||
$session = New-PSSession `
|
|
||||||
-ComputerName $IPAddress `
|
|
||||||
-Credential $Credential `
|
|
||||||
-SessionOption $sessionOptions `
|
|
||||||
-UseSSL `
|
|
||||||
-Port 5986 `
|
|
||||||
-Authentication Basic `
|
|
||||||
-ErrorAction Stop
|
|
||||||
|
|
||||||
try {
|
|
||||||
# ── Ensure working directories exist on guest ────────────────────────
|
|
||||||
Invoke-Command -Session $session -ScriptBlock {
|
|
||||||
param($workDir, $outputDir)
|
|
||||||
# Ensure artifact output parent exists
|
|
||||||
$outParent = Split-Path $outputDir -Parent
|
|
||||||
if (-not (Test-Path $outParent)) {
|
|
||||||
New-Item -ItemType Directory -Path $outParent -Force | Out-Null
|
|
||||||
}
|
|
||||||
# Clean and recreate build dir (ephemeral VM, but guard against partial prior run)
|
|
||||||
if (Test-Path $workDir) { Remove-Item $workDir -Recurse -Force }
|
|
||||||
New-Item -ItemType Directory -Path $workDir -Force | Out-Null
|
|
||||||
} -ArgumentList $GuestWorkDir, $GuestArtifactZip
|
|
||||||
|
|
||||||
# ── Source preparation: Host clone (compress+transfer) or Guest clone (git) ──
|
|
||||||
if ($hostCloneMode) {
|
|
||||||
# Mode 1: Copy pre-cloned source from host to guest via WinRM zip
|
|
||||||
# Single-file transfer over WinRM is far faster than recursive Copy-Item.
|
|
||||||
$hostZip = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.zip'
|
|
||||||
try {
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Compressing source on host..."
|
|
||||||
Compress-BuildArtifact -Path "$HostSourceDir\*" -DestinationPath $hostZip
|
|
||||||
$zipSize = [math]::Round((Get-Item $hostZip).Length / 1MB, 1)
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Transferring archive to guest ($zipSize MB)..."
|
|
||||||
$guestZip = 'C:\CI\src-transfer.zip'
|
|
||||||
Copy-Item -Path $hostZip -Destination $guestZip -ToSession $session -Force
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Expanding archive in guest..."
|
|
||||||
Invoke-Command -Session $session -ScriptBlock {
|
|
||||||
param($zip, $dest)
|
|
||||||
Expand-Archive -Path $zip -DestinationPath $dest -Force
|
|
||||||
Remove-Item $zip -Force
|
|
||||||
} -ArgumentList $guestZip, $GuestWorkDir
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Source transfer complete."
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
Remove-Item $hostZip -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# Mode 2 (§3.3): Clone repo directly in guest via git
|
|
||||||
# PAT (if needed) read from Credential Manager inside session, never logged
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Cloning repository in guest (§3.3)..."
|
|
||||||
|
|
||||||
# Read PAT from host Credential Manager (§1.5: never in argv or logs)
|
|
||||||
$gitPatUser = $null
|
|
||||||
$gitPatPass = $null
|
|
||||||
if (-not [string]::IsNullOrWhiteSpace($GiteaCredentialTarget)) {
|
|
||||||
try {
|
|
||||||
$stored = Get-StoredCredential -Target $GiteaCredentialTarget -ErrorAction Stop
|
|
||||||
$gitPatUser = $stored.UserName
|
|
||||||
$gitPatPass = $stored.GetNetworkCredential().Password
|
|
||||||
Write-Host "[Invoke-RemoteBuild] PAT loaded from Credential Manager ($GiteaCredentialTarget)."
|
|
||||||
} catch {
|
|
||||||
Write-Warning "[Invoke-RemoteBuild] Could not read PAT from Credential Manager '$GiteaCredentialTarget' — proceeding without auth (public repo only)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Clone in guest via WinRM
|
|
||||||
Invoke-Command -Session $session -ScriptBlock {
|
|
||||||
param($cloneUrl, $branch, $commit, $workDir, $patUser, $patPass, $submodules)
|
|
||||||
|
|
||||||
Set-Location (Split-Path $workDir -Parent)
|
|
||||||
$gitArgs = @('clone', '--depth', '1', '--branch', $branch)
|
|
||||||
if ($submodules) { $gitArgs += '--recurse-submodules' }
|
|
||||||
$gitArgs += @($cloneUrl, (Split-Path $workDir -Leaf))
|
|
||||||
|
|
||||||
# Disable interactive credential prompts — WinRM has no TTY
|
|
||||||
$env:GIT_TERMINAL_PROMPT = '0'
|
|
||||||
|
|
||||||
# Build git config args:
|
|
||||||
# - credential.helper= (empty) clears GCM and any system credential helper
|
|
||||||
# - http.extraHeader injects Basic auth without credential helpers, temp files,
|
|
||||||
# or modifying the clone URL (§1.5: token never in URL, argv display, or logs)
|
|
||||||
$gitConfigArgs = @('-c', 'credential.helper=')
|
|
||||||
if ($patUser -and $patPass) {
|
|
||||||
$b64 = [Convert]::ToBase64String(
|
|
||||||
[Text.Encoding]::UTF8.GetBytes("${patUser}:${patPass}"))
|
|
||||||
$gitConfigArgs += @('-c', "http.extraHeader=Authorization: Basic $b64")
|
|
||||||
}
|
|
||||||
|
|
||||||
$gitArgs = $gitConfigArgs + $gitArgs
|
|
||||||
|
|
||||||
Write-Host "[Guest] Cloning $cloneUrl (branch: $branch)..."
|
|
||||||
& git @gitArgs 2>&1 | ForEach-Object { Write-Host "[Guest Clone] $_" }
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "git clone failed in guest (exit $LASTEXITCODE)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Checkout specific commit if provided
|
|
||||||
if ($commit) {
|
|
||||||
Write-Host "[Guest] Checking out commit: $commit"
|
|
||||||
Set-Location $workDir
|
|
||||||
& git checkout $commit 2>&1 | ForEach-Object { Write-Host "[Guest Checkout] $_" }
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "git checkout $commit failed in guest (exit $LASTEXITCODE)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} -ArgumentList $CloneUrl, $CloneBranch, $CloneCommit, $GuestWorkDir, $gitPatUser, $gitPatPass, $CloneSubmodules.IsPresent
|
|
||||||
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Guest clone complete."
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Build ──────────────────────────────────────────────────────────
|
|
||||||
if ($BuildCommand -ne '') {
|
|
||||||
# ── Custom build command (e.g. python, msbuild, cmake…) ──────────
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Running build command: $BuildCommand"
|
|
||||||
$buildExit = Invoke-Command -Session $session -ScriptBlock {
|
|
||||||
param($workDir, $cmd, $artifactZip, $artifactSrc, $useCache, $extraEnv, $skipArt)
|
|
||||||
# Unbuffered Python output — lines appear in real-time instead of being held in buffer
|
|
||||||
$env:PYTHONUNBUFFERED = '1'
|
|
||||||
if ($useCache) {
|
|
||||||
$env:PIP_CACHE_DIR = '\\vmware-host\Shared Folders\pip-cache'
|
|
||||||
}
|
|
||||||
# Inject extra env vars (§6.5 secret injection)
|
|
||||||
foreach ($pair in $extraEnv.GetEnumerator()) {
|
|
||||||
[System.Environment]::SetEnvironmentVariable($pair.Key, $pair.Value, 'Process')
|
|
||||||
}
|
|
||||||
Set-Location $workDir
|
|
||||||
# Stream each output line via Write-Host — WinRM forwards Write-Host in real-time
|
|
||||||
& cmd /c "$cmd 2>&1" | ForEach-Object { Write-Host $_ }
|
|
||||||
$exit = $LASTEXITCODE
|
|
||||||
|
|
||||||
if ($exit -eq 0 -and -not $skipArt) {
|
|
||||||
$archiveDir = Split-Path $artifactZip -Parent
|
|
||||||
if (-not (Test-Path $archiveDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
|
|
||||||
}
|
|
||||||
$srcPath = Join-Path $workDir $artifactSrc
|
|
||||||
if (-not (Test-Path $srcPath)) {
|
|
||||||
throw "Artifact source directory not found: $srcPath. Build succeeded (exit 0) but produced no output. Check GuestArtifactSource or set skip-artifact: 'true' in the workflow."
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$7zip = 'C:\Program Files\7-Zip\7z.exe'
|
|
||||||
if (Test-Path $7zip) {
|
|
||||||
Write-Host "[Build] Compressing artifacts with 7-Zip..."
|
|
||||||
& $7zip a -mmt=on -mx1 $artifactZip $srcPath 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ }
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Compress-Archive -Path $srcPath -DestinationPath $artifactZip -Force
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $exit
|
|
||||||
} -ArgumentList $GuestWorkDir, $BuildCommand, $GuestArtifactZip, $GuestArtifactSource, $UseSharedCache.IsPresent, $ExtraGuestEnv, $SkipArtifact.IsPresent
|
|
||||||
|
|
||||||
if ($buildExit -ne 0) {
|
|
||||||
throw "Build command failed (exit $buildExit)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
# ── Default: dotnet restore + dotnet build ────────────────────────
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Running dotnet restore..."
|
|
||||||
$restoreExit = Invoke-Command -Session $session -ScriptBlock {
|
|
||||||
param($workDir, $useCache)
|
|
||||||
if ($useCache) {
|
|
||||||
$env:NUGET_PACKAGES = '\\vmware-host\Shared Folders\nuget-cache'
|
|
||||||
}
|
|
||||||
Set-Location $workDir
|
|
||||||
dotnet restore 2>&1 | ForEach-Object { Write-Host $_ }
|
|
||||||
return $LASTEXITCODE
|
|
||||||
} -ArgumentList $GuestWorkDir, $UseSharedCache.IsPresent
|
|
||||||
|
|
||||||
if ($restoreExit -ne 0) {
|
|
||||||
throw "dotnet restore failed (exit $restoreExit)"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Running dotnet build (configuration: $Configuration)..."
|
|
||||||
$buildExit = Invoke-Command -Session $session -ScriptBlock {
|
|
||||||
param($workDir, $config, $artifactZip, $useCache)
|
|
||||||
if ($useCache) {
|
|
||||||
$env:NUGET_PACKAGES = '\\vmware-host\Shared Folders\nuget-cache'
|
|
||||||
}
|
|
||||||
Set-Location $workDir
|
|
||||||
$outputDir = Join-Path $workDir 'bin\CIOutput'
|
|
||||||
dotnet build --configuration $config --output $outputDir 2>&1 | ForEach-Object { Write-Host $_ }
|
|
||||||
$exit = $LASTEXITCODE
|
|
||||||
|
|
||||||
if ($exit -eq 0) {
|
|
||||||
$archiveDir = Split-Path $artifactZip -Parent
|
|
||||||
if (-not (Test-Path $archiveDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
|
|
||||||
}
|
|
||||||
if (-not (Test-Path $outputDir)) {
|
|
||||||
Write-Host "[Build] Output dir '$outputDir' not found — skipping packaging."
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$7zip = 'C:\Program Files\7-Zip\7z.exe'
|
|
||||||
if (Test-Path $7zip) {
|
|
||||||
Write-Host "[Build] Compressing output with 7-Zip..."
|
|
||||||
& $7zip a -mmt=on -mx1 $artifactZip "$outputDir\*" 2>&1 | Where-Object { $_ -notmatch '^7-Zip' } | ForEach-Object { Write-Host $_ }
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "7-Zip failed" }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
Compress-Archive -Path "$outputDir\*" -DestinationPath $artifactZip -Force
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $exit
|
|
||||||
} -ArgumentList $GuestWorkDir, $Configuration, $GuestArtifactZip, $UseSharedCache.IsPresent
|
|
||||||
|
|
||||||
if ($buildExit -ne 0) {
|
|
||||||
throw "dotnet build failed (exit $buildExit)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($SkipArtifact) {
|
|
||||||
Write-Host '[Invoke-RemoteBuild] Build succeeded. Artifact packaging skipped (-SkipArtifact).'
|
|
||||||
} else {
|
|
||||||
Write-Host "[Invoke-RemoteBuild] Build succeeded. Artifact at $GuestArtifactZip"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 build run @pyArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
|||||||
+15
-82
@@ -1,43 +1,10 @@
|
|||||||
#Requires -Version 5.1
|
#Requires -Version 5.1
|
||||||
<#
|
Set-StrictMode -Version Latest
|
||||||
.SYNOPSIS
|
$ErrorActionPreference = 'Stop'
|
||||||
Creates a linked clone of the template VM for an ephemeral CI build.
|
|
||||||
|
|
||||||
.DESCRIPTION
|
# Shim: delegates to the Python ci_orchestrator CLI (`vm new`).
|
||||||
Uses vmrun.exe to create a linked clone from a frozen template snapshot.
|
# Original PS implementation moved to git history; see plans/A3-closeout.md.
|
||||||
Returns the full path to the new clone's .vmx file on success.
|
|
||||||
The template VM and its snapshot are never modified.
|
|
||||||
|
|
||||||
.PARAMETER TemplatePath
|
|
||||||
Full path to the template VM's .vmx file.
|
|
||||||
Example: F:\CI\Templates\WinBuild\WinBuild.vmx
|
|
||||||
|
|
||||||
.PARAMETER SnapshotName
|
|
||||||
Name of the snapshot on the template VM to clone from.
|
|
||||||
Default: BaseClean
|
|
||||||
|
|
||||||
.PARAMETER CloneBaseDir
|
|
||||||
Directory under which the new clone folder will be created.
|
|
||||||
The clone folder is named after the job ID and timestamp.
|
|
||||||
Example: F:\CI\BuildVMs
|
|
||||||
|
|
||||||
.PARAMETER JobId
|
|
||||||
Unique identifier for the CI job (e.g. run ID from Gitea Actions).
|
|
||||||
Used to name the clone: Clone_{JobId}_{yyyyMMdd_HHmmss}
|
|
||||||
|
|
||||||
.PARAMETER VmrunPath
|
|
||||||
Full path to vmrun.exe.
|
|
||||||
Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe
|
|
||||||
|
|
||||||
.OUTPUTS
|
|
||||||
[string] Full path to the new clone's .vmx file.
|
|
||||||
|
|
||||||
.EXAMPLE
|
|
||||||
$vmxPath = .\New-BuildVM.ps1 `
|
|
||||||
-TemplatePath "F:\CI\Templates\WinBuild\WinBuild.vmx" `
|
|
||||||
-CloneBaseDir "F:\CI\BuildVMs" `
|
|
||||||
-JobId "run-42"
|
|
||||||
#>
|
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory)]
|
[Parameter(Mandatory)]
|
||||||
@@ -55,49 +22,15 @@ param(
|
|||||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
||||||
)
|
)
|
||||||
|
|
||||||
Set-StrictMode -Version Latest
|
$pyArgs = @(
|
||||||
$ErrorActionPreference = 'Stop'
|
'--template-path', $TemplatePath,
|
||||||
|
'--snapshot-name', $SnapshotName,
|
||||||
|
'--clone-base-dir', $CloneBaseDir,
|
||||||
|
'--job-id', $JobId,
|
||||||
|
'--vmrun-path', $VmrunPath
|
||||||
|
)
|
||||||
|
|
||||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
$venvPython = $env:CI_VENV_PYTHON
|
||||||
|
if (-not $venvPython) { $venvPython = 'F:\CI\python\venv\Scripts\python.exe' }
|
||||||
Resolve-VmrunPath -VmrunPath $VmrunPath | Out-Null
|
& $venvPython -m ci_orchestrator vm new @pyArgs
|
||||||
|
exit $LASTEXITCODE
|
||||||
# --- Build clone path ---
|
|
||||||
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
|
|
||||||
$cloneName = "Clone_${JobId}_${timestamp}"
|
|
||||||
$cloneDir = Join-Path $CloneBaseDir $cloneName
|
|
||||||
$cloneVmx = Join-Path $cloneDir "$cloneName.vmx"
|
|
||||||
|
|
||||||
# Ensure clone base dir exists
|
|
||||||
if (-not (Test-Path $CloneBaseDir)) {
|
|
||||||
New-Item -ItemType Directory -Path $CloneBaseDir -Force | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[New-BuildVM] Creating linked clone..."
|
|
||||||
Write-Host " Template : $TemplatePath"
|
|
||||||
Write-Host " Snapshot : $SnapshotName"
|
|
||||||
Write-Host " Clone VMX : $cloneVmx"
|
|
||||||
|
|
||||||
$sw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
||||||
|
|
||||||
$cloneResult = Invoke-Vmrun -VmrunPath $VmrunPath -Operation clone `
|
|
||||||
-Arguments @($TemplatePath, $cloneVmx, 'linked', '-snapshot', $SnapshotName)
|
|
||||||
|
|
||||||
$sw.Stop()
|
|
||||||
|
|
||||||
if ($cloneResult.ExitCode -ne 0) {
|
|
||||||
# Clean up partial clone directory if it was created
|
|
||||||
if (Test-Path $cloneDir) {
|
|
||||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
throw "vmrun clone failed (exit $($cloneResult.ExitCode)).`nOutput: $($cloneResult.Output)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Test-Path $cloneVmx -PathType Leaf)) {
|
|
||||||
throw "vmrun reported success but clone VMX not found at: $cloneVmx"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[New-BuildVM] Clone created in $($sw.Elapsed.TotalSeconds.ToString('F1'))s : $cloneVmx"
|
|
||||||
|
|
||||||
# Return the VMX path as output
|
|
||||||
return $cloneVmx
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""CLI entry point.
|
"""CLI entry point.
|
||||||
|
|
||||||
Phase A2 ships ``wait-ready``, ``vm`` (remove/cleanup), ``monitor``
|
Phase A2 ships ``wait-ready``, ``vm`` (remove/cleanup), ``monitor``
|
||||||
(disk/runner) and ``report`` (job). Subsequent phases add ``vm new``,
|
(disk/runner) and ``report`` (job). Phase A3 adds ``vm new``, ``build``,
|
||||||
``build``, ``artifacts``, ``job``.
|
``artifacts``. Subsequent phases add ``job``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,6 +12,8 @@ import time as time
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from ci_orchestrator import __version__
|
from ci_orchestrator import __version__
|
||||||
|
from ci_orchestrator.commands.artifacts import artifacts
|
||||||
|
from ci_orchestrator.commands.build import build
|
||||||
from ci_orchestrator.commands.monitor import monitor
|
from ci_orchestrator.commands.monitor import monitor
|
||||||
from ci_orchestrator.commands.report import report
|
from ci_orchestrator.commands.report import report
|
||||||
from ci_orchestrator.commands.vm import vm
|
from ci_orchestrator.commands.vm import vm
|
||||||
@@ -34,6 +36,8 @@ def cli() -> None:
|
|||||||
|
|
||||||
cli.add_command(wait_ready)
|
cli.add_command(wait_ready)
|
||||||
cli.add_command(vm)
|
cli.add_command(vm)
|
||||||
|
cli.add_command(build)
|
||||||
|
cli.add_command(artifacts)
|
||||||
cli.add_command(monitor)
|
cli.add_command(monitor)
|
||||||
cli.add_command(report)
|
cli.add_command(report)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"""``artifacts`` sub-commands.
|
||||||
|
|
||||||
|
Phase A3 ports the artifact-collection script:
|
||||||
|
|
||||||
|
* ``artifacts collect`` — replaces ``scripts/Get-BuildArtifacts.ps1``
|
||||||
|
|
||||||
|
Uses the abstract :meth:`~ci_orchestrator.transport.winrm.WinRmTransport.fetch`
|
||||||
|
and the SSH transport (via a tar+fetch helper) so no platform-specific
|
||||||
|
``Copy-Item`` / ``scp.exe`` invocations leak into command code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from ci_orchestrator.config import load_config
|
||||||
|
from ci_orchestrator.credentials import KeyringCredentialStore
|
||||||
|
from ci_orchestrator.transport.errors import TransportCommandError, TransportError
|
||||||
|
from ci_orchestrator.transport.ssh import SshTransport
|
||||||
|
from ci_orchestrator.transport.winrm import WinRmTransport
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- group
|
||||||
|
|
||||||
|
|
||||||
|
@click.group("artifacts")
|
||||||
|
def artifacts() -> None:
|
||||||
|
"""Artifact transfer commands."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
|
||||||
|
def _sh_quote(value: str) -> str:
|
||||||
|
return shlex.quote(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _ps_quote(value: str) -> str:
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_hex(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(64 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_manifest(host_dir: Path, job_id: str, commit: str) -> int:
|
||||||
|
"""Write ``manifest.json`` summarising every collected file. Returns count."""
|
||||||
|
files = [
|
||||||
|
p
|
||||||
|
for p in host_dir.rglob("*")
|
||||||
|
if p.is_file() and p.name != "manifest.json"
|
||||||
|
]
|
||||||
|
entries = []
|
||||||
|
for f in files:
|
||||||
|
rel = f.relative_to(host_dir).as_posix()
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"name": rel,
|
||||||
|
"size": f.stat().st_size,
|
||||||
|
"sha256": _sha256_hex(f),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"ts": datetime.now(tz=UTC).isoformat(),
|
||||||
|
"jobId": job_id,
|
||||||
|
"commit": commit,
|
||||||
|
"files": entries,
|
||||||
|
}
|
||||||
|
out = host_dir / "manifest.json"
|
||||||
|
out.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
return len(entries)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Linux flow
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_collect(
|
||||||
|
*,
|
||||||
|
ip_address: str,
|
||||||
|
ssh_user: str,
|
||||||
|
key_path: str | None,
|
||||||
|
known_hosts: str | None,
|
||||||
|
guest_path: str,
|
||||||
|
host_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Tar the guest directory, fetch the single archive, extract locally."""
|
||||||
|
click.echo(f"[artifacts collect] tarring guest:{guest_path}")
|
||||||
|
archive_remote = f"/tmp/ci-artifacts-{ip_address.replace('.', '-')}.tar.gz"
|
||||||
|
with SshTransport(
|
||||||
|
ip_address,
|
||||||
|
username=ssh_user,
|
||||||
|
key_path=key_path,
|
||||||
|
known_hosts=known_hosts,
|
||||||
|
) as t:
|
||||||
|
# Build a tarball whose contents are *inside* guest_path. Strip the
|
||||||
|
# parent component so files land directly under host_dir.
|
||||||
|
t.run(
|
||||||
|
f"tar -czf {_sh_quote(archive_remote)} "
|
||||||
|
f"-C {_sh_quote(guest_path)} ."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
local_tar = Path(td) / "artifacts.tar.gz"
|
||||||
|
t.fetch(archive_remote, str(local_tar))
|
||||||
|
with tarfile.open(local_tar, "r:gz") as tf:
|
||||||
|
# ``data`` filter (Python 3.12+) protects against path
|
||||||
|
# traversal in malicious tarballs.
|
||||||
|
tf.extractall(host_dir, filter="data")
|
||||||
|
finally:
|
||||||
|
t.run(f"rm -f {_sh_quote(archive_remote)}", check=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Windows flow
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_collect(
|
||||||
|
*,
|
||||||
|
ip_address: str,
|
||||||
|
credential_target: str,
|
||||||
|
guest_path: str,
|
||||||
|
host_dir: Path,
|
||||||
|
include_logs: bool,
|
||||||
|
) -> None:
|
||||||
|
cred = KeyringCredentialStore().get(credential_target)
|
||||||
|
with WinRmTransport(ip_address, cred.username, cred.password) as t:
|
||||||
|
# Verify artifact exists.
|
||||||
|
probe = t.run(
|
||||||
|
f"if (Test-Path {_ps_quote(guest_path)}) {{ 'OK' }} else {{ 'MISSING' }}",
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if "MISSING" in probe.stdout:
|
||||||
|
raise click.ClickException(f"artifact not found in guest: {guest_path}")
|
||||||
|
|
||||||
|
artifact_name = Path(guest_path).name
|
||||||
|
host_dest = host_dir / artifact_name
|
||||||
|
click.echo(f"[artifacts collect] fetching {artifact_name}")
|
||||||
|
t.fetch(guest_path, str(host_dest))
|
||||||
|
|
||||||
|
if include_logs:
|
||||||
|
# List log files in the build dir and fetch each one.
|
||||||
|
list_ps = (
|
||||||
|
"Get-ChildItem -Path 'C:\\CI\\build' -Filter '*.log' -Recurse "
|
||||||
|
"-ErrorAction SilentlyContinue | "
|
||||||
|
"ForEach-Object { $_.FullName }"
|
||||||
|
)
|
||||||
|
listing = t.run(list_ps, check=False)
|
||||||
|
for line in (listing.stdout or "").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
dest = host_dir / Path(line).name
|
||||||
|
click.echo(f"[artifacts collect] fetching log: {Path(line).name}")
|
||||||
|
try:
|
||||||
|
t.fetch(line, str(dest))
|
||||||
|
except TransportError as exc:
|
||||||
|
click.echo(
|
||||||
|
f"[artifacts collect] log fetch failed ({line}): {exc}",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not host_dest.is_file() or host_dest.stat().st_size == 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"artifact missing or empty after fetch: {host_dest}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- CLI
|
||||||
|
|
||||||
|
|
||||||
|
@artifacts.command("collect")
|
||||||
|
@click.option("--ip-address", "ip_address", required=True)
|
||||||
|
@click.option(
|
||||||
|
"--guest-os",
|
||||||
|
type=click.Choice(["windows", "linux"], case_sensitive=False),
|
||||||
|
default="windows",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--guest-artifact-path",
|
||||||
|
"guest_artifact_path",
|
||||||
|
required=True,
|
||||||
|
help="Guest path: file (Windows) or directory (Linux).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--host-artifact-dir",
|
||||||
|
"host_artifact_dir",
|
||||||
|
required=True,
|
||||||
|
help="Local directory to deposit artifacts (created if missing).",
|
||||||
|
)
|
||||||
|
@click.option("--include-logs", "include_logs", is_flag=True, default=False)
|
||||||
|
@click.option("--credential-target", "credential_target", default=None)
|
||||||
|
@click.option("--ssh-user", "ssh_user", default="ci_build", show_default=True)
|
||||||
|
@click.option("--ssh-key-path", "ssh_key_path", default=None)
|
||||||
|
@click.option("--ssh-known-hosts-file", "ssh_known_hosts_file", default=None)
|
||||||
|
@click.option("--job-id", "job_id", default="")
|
||||||
|
@click.option("--commit", "commit", default="")
|
||||||
|
def artifacts_collect(
|
||||||
|
ip_address: str,
|
||||||
|
guest_os: str,
|
||||||
|
guest_artifact_path: str,
|
||||||
|
host_artifact_dir: str,
|
||||||
|
include_logs: bool,
|
||||||
|
credential_target: str | None,
|
||||||
|
ssh_user: str,
|
||||||
|
ssh_key_path: str | None,
|
||||||
|
ssh_known_hosts_file: str | None,
|
||||||
|
job_id: str,
|
||||||
|
commit: str,
|
||||||
|
) -> None:
|
||||||
|
"""Copy build artifacts from a guest VM to a local directory.
|
||||||
|
|
||||||
|
Mirrors ``scripts/Get-BuildArtifacts.ps1``. Writes ``manifest.json``
|
||||||
|
when ``--job-id`` or ``--commit`` is provided.
|
||||||
|
"""
|
||||||
|
guest_os = guest_os.lower()
|
||||||
|
host_dir = Path(host_artifact_dir)
|
||||||
|
host_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if guest_os == "linux":
|
||||||
|
key_path: str | None = ssh_key_path
|
||||||
|
if key_path is None and config.ssh_key_path is not None:
|
||||||
|
key_path = str(config.ssh_key_path)
|
||||||
|
_linux_collect(
|
||||||
|
ip_address=ip_address,
|
||||||
|
ssh_user=ssh_user,
|
||||||
|
key_path=key_path,
|
||||||
|
known_hosts=ssh_known_hosts_file,
|
||||||
|
guest_path=guest_artifact_path,
|
||||||
|
host_dir=host_dir,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
target = credential_target or config.guest_cred_target
|
||||||
|
_windows_collect(
|
||||||
|
ip_address=ip_address,
|
||||||
|
credential_target=target,
|
||||||
|
guest_path=guest_artifact_path,
|
||||||
|
host_dir=host_dir,
|
||||||
|
include_logs=include_logs,
|
||||||
|
)
|
||||||
|
except (TransportError, TransportCommandError) as exc:
|
||||||
|
raise click.ClickException(str(exc)) from exc
|
||||||
|
|
||||||
|
transferred = [p for p in host_dir.rglob("*") if p.is_file()]
|
||||||
|
if not transferred:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"no files were transferred into {host_dir}"
|
||||||
|
)
|
||||||
|
click.echo(f"[artifacts collect] {len(transferred)} file(s) collected.")
|
||||||
|
|
||||||
|
if job_id or commit:
|
||||||
|
count = _write_manifest(host_dir, job_id, commit)
|
||||||
|
click.echo(f"[artifacts collect] manifest.json written ({count} file(s)).")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["artifacts", "artifacts_collect"]
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""``build`` sub-commands.
|
||||||
|
|
||||||
|
Phase A3 ports the remote build pipeline:
|
||||||
|
|
||||||
|
* ``build run`` — replaces ``scripts/Invoke-RemoteBuild.ps1``
|
||||||
|
|
||||||
|
The original PS script supports two source-provisioning modes (host-side
|
||||||
|
clone uploaded via WinRM/SCP, and in-guest ``git clone``) plus a custom
|
||||||
|
build command and optional artifact packaging. This Python port preserves
|
||||||
|
the mechanics while keeping the guest workdir and artifact paths fully
|
||||||
|
parameterised — see Phase C hooks below.
|
||||||
|
|
||||||
|
Phase C hook: the guest workdir / artifact paths are passed as opaque
|
||||||
|
strings; this module makes no assumption about Windows vs POSIX path
|
||||||
|
syntax beyond a sensible default per ``--guest-os``. ``build run`` does
|
||||||
|
not import ``WorkstationVmrunBackend`` directly; it relies on the
|
||||||
|
transport layer to talk to whatever guest the orchestrator points it at.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from ci_orchestrator.config import load_config
|
||||||
|
from ci_orchestrator.credentials import KeyringCredentialStore
|
||||||
|
from ci_orchestrator.transport.errors import TransportCommandError, TransportError
|
||||||
|
from ci_orchestrator.transport.ssh import SshTransport
|
||||||
|
from ci_orchestrator.transport.winrm import WinRmTransport
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- group
|
||||||
|
|
||||||
|
|
||||||
|
@click.group("build")
|
||||||
|
def build() -> None:
|
||||||
|
"""Remote build commands."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_extra_env(items: Iterable[str]) -> dict[str, str]:
|
||||||
|
"""Parse ``KEY=VALUE`` pairs into a dict.
|
||||||
|
|
||||||
|
Empty entries are ignored. A pair without ``=`` raises ``BadParameter``.
|
||||||
|
"""
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for raw in items:
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
if "=" not in raw:
|
||||||
|
raise click.BadParameter(
|
||||||
|
f"--extra-env value must be KEY=VALUE, got: {raw!r}"
|
||||||
|
)
|
||||||
|
key, value = raw.split("=", 1)
|
||||||
|
if not key:
|
||||||
|
raise click.BadParameter(f"--extra-env key cannot be empty: {raw!r}")
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_dir(source_dir: Path, archive_path: Path) -> None:
|
||||||
|
"""Create a deterministic ZIP archive containing the contents of ``source_dir``."""
|
||||||
|
with zipfile.ZipFile(
|
||||||
|
archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1
|
||||||
|
) as zf:
|
||||||
|
for item in source_dir.rglob("*"):
|
||||||
|
if item.is_file():
|
||||||
|
zf.write(item, arcname=item.relative_to(source_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def _tar_dir(source_dir: Path, archive_path: Path) -> None:
|
||||||
|
"""Create a gzip tar archive containing the contents of ``source_dir``."""
|
||||||
|
with tarfile.open(archive_path, "w:gz") as tf:
|
||||||
|
for item in source_dir.iterdir():
|
||||||
|
tf.add(item, arcname=item.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _ps_quote(value: str) -> str:
|
||||||
|
"""Quote a value for embedding in a PowerShell single-quoted string."""
|
||||||
|
return "'" + value.replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def _sh_quote(value: str) -> str:
|
||||||
|
"""POSIX shell single-quote escaping."""
|
||||||
|
return shlex.quote(value)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Linux flow
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_build(
|
||||||
|
*,
|
||||||
|
ip_address: str,
|
||||||
|
ssh_user: str,
|
||||||
|
key_path: str | None,
|
||||||
|
known_hosts: str | None,
|
||||||
|
workdir: str,
|
||||||
|
output_dir: str,
|
||||||
|
host_source_dir: str | None,
|
||||||
|
clone_url: str | None,
|
||||||
|
clone_branch: str,
|
||||||
|
clone_commit: str,
|
||||||
|
clone_submodules: bool,
|
||||||
|
build_command: str,
|
||||||
|
artifact_source: str,
|
||||||
|
extra_env: dict[str, str],
|
||||||
|
skip_artifact: bool,
|
||||||
|
) -> None:
|
||||||
|
click.echo("[build run] Linux build mode (SSH transport)")
|
||||||
|
|
||||||
|
with SshTransport(
|
||||||
|
ip_address,
|
||||||
|
username=ssh_user,
|
||||||
|
key_path=key_path,
|
||||||
|
known_hosts=known_hosts,
|
||||||
|
) as t:
|
||||||
|
t.run(f"rm -rf {_sh_quote(workdir)} && mkdir -p {_sh_quote(workdir)}")
|
||||||
|
|
||||||
|
if host_source_dir:
|
||||||
|
click.echo(f"[build run] packaging host source: {host_source_dir}")
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
tar_path = Path(td) / "ci-src.tar.gz"
|
||||||
|
_tar_dir(Path(host_source_dir), tar_path)
|
||||||
|
guest_tar = f"/tmp/{tar_path.name}"
|
||||||
|
t.copy(str(tar_path), guest_tar)
|
||||||
|
click.echo(f"[build run] extracting source in guest: {workdir}")
|
||||||
|
t.run(
|
||||||
|
f"tar -xzf {_sh_quote(guest_tar)} -C {_sh_quote(workdir)} "
|
||||||
|
f"&& rm -f {_sh_quote(guest_tar)}"
|
||||||
|
)
|
||||||
|
elif clone_url:
|
||||||
|
click.echo(f"[build run] cloning {clone_url} into guest:{workdir}")
|
||||||
|
cmd = (
|
||||||
|
f"git clone --depth 1 --branch {_sh_quote(clone_branch)} "
|
||||||
|
f"{'--recurse-submodules ' if clone_submodules else ''}"
|
||||||
|
f"{_sh_quote(clone_url)} {_sh_quote(workdir)}"
|
||||||
|
)
|
||||||
|
t.run(cmd)
|
||||||
|
if clone_commit:
|
||||||
|
t.run(
|
||||||
|
f"git -C {_sh_quote(workdir)} checkout {_sh_quote(clone_commit)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise click.ClickException(
|
||||||
|
"Linux build requires either --host-source-dir or --clone-url."
|
||||||
|
)
|
||||||
|
|
||||||
|
env_prefix = "".join(
|
||||||
|
f"export {k}={_sh_quote(v)}; " for k, v in extra_env.items()
|
||||||
|
)
|
||||||
|
cmd = build_command or "make"
|
||||||
|
full_cmd = f"{env_prefix}cd {_sh_quote(workdir)} && {cmd}"
|
||||||
|
click.echo(f"[build run] running build (env vars redacted): cd {workdir} && {cmd}")
|
||||||
|
result = t.run(full_cmd, check=False)
|
||||||
|
# Stream guest stdout/stderr after the fact (paramiko buffers the channel).
|
||||||
|
if result.stdout:
|
||||||
|
sys.stdout.write(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
sys.stderr.write(result.stderr)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"build command failed (exit {result.returncode})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if skip_artifact:
|
||||||
|
click.echo("[build run] artifact packaging skipped (--skip-artifact).")
|
||||||
|
return
|
||||||
|
|
||||||
|
t.run(f"rm -rf {_sh_quote(output_dir)} && mkdir -p {_sh_quote(output_dir)}")
|
||||||
|
src = artifact_source or "dist"
|
||||||
|
cp_cmd = (
|
||||||
|
f"if [ -d {_sh_quote(workdir + '/' + src)} ]; then "
|
||||||
|
f"cp -r {_sh_quote(workdir + '/' + src + '/.')} {_sh_quote(output_dir + '/')}; "
|
||||||
|
f"elif [ -e {_sh_quote(workdir + '/' + src)} ]; then "
|
||||||
|
f"cp {_sh_quote(workdir + '/' + src)} {_sh_quote(output_dir + '/')}; "
|
||||||
|
f"else echo '[build run] WARNING: artifact source not found: {src}' >&2; fi"
|
||||||
|
)
|
||||||
|
t.run(cp_cmd)
|
||||||
|
click.echo(f"[build run] artifact staged at {output_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Windows flow
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_build(
|
||||||
|
*,
|
||||||
|
ip_address: str,
|
||||||
|
credential_target: str,
|
||||||
|
workdir: str,
|
||||||
|
artifact_zip: str,
|
||||||
|
host_source_dir: str | None,
|
||||||
|
clone_url: str | None,
|
||||||
|
clone_branch: str,
|
||||||
|
clone_commit: str,
|
||||||
|
clone_submodules: bool,
|
||||||
|
build_command: str,
|
||||||
|
artifact_source: str,
|
||||||
|
configuration: str,
|
||||||
|
extra_env: dict[str, str],
|
||||||
|
skip_artifact: bool,
|
||||||
|
use_shared_cache: bool,
|
||||||
|
) -> None:
|
||||||
|
click.echo("[build run] Windows build mode (WinRM transport)")
|
||||||
|
cred = KeyringCredentialStore().get(credential_target)
|
||||||
|
|
||||||
|
with WinRmTransport(ip_address, cred.username, cred.password) as t:
|
||||||
|
# Prepare workdir + artifact output directory.
|
||||||
|
prep_ps = (
|
||||||
|
f"$work = {_ps_quote(workdir)}; "
|
||||||
|
f"$out = Split-Path {_ps_quote(artifact_zip)} -Parent; "
|
||||||
|
"if (-not (Test-Path $out)) { New-Item -ItemType Directory -Path $out -Force | Out-Null }; "
|
||||||
|
"if (Test-Path $work) { Remove-Item $work -Recurse -Force }; "
|
||||||
|
"New-Item -ItemType Directory -Path $work -Force | Out-Null"
|
||||||
|
)
|
||||||
|
t.run(prep_ps)
|
||||||
|
|
||||||
|
if host_source_dir:
|
||||||
|
click.echo(f"[build run] zipping host source: {host_source_dir}")
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
zip_path = Path(td) / "ci-src.zip"
|
||||||
|
_zip_dir(Path(host_source_dir), zip_path)
|
||||||
|
size_mb = round(zip_path.stat().st_size / (1024 * 1024), 1)
|
||||||
|
click.echo(f"[build run] uploading source archive ({size_mb} MB)")
|
||||||
|
guest_zip = "C:\\CI\\src-transfer.zip"
|
||||||
|
t.copy(str(zip_path), guest_zip)
|
||||||
|
t.run(
|
||||||
|
f"Expand-Archive -Path {_ps_quote(guest_zip)} "
|
||||||
|
f"-DestinationPath {_ps_quote(workdir)} -Force; "
|
||||||
|
f"Remove-Item {_ps_quote(guest_zip)} -Force"
|
||||||
|
)
|
||||||
|
elif clone_url:
|
||||||
|
click.echo(f"[build run] cloning {clone_url} into guest:{workdir}")
|
||||||
|
sub_flag = " --recurse-submodules" if clone_submodules else ""
|
||||||
|
t.run(
|
||||||
|
f"$env:GIT_TERMINAL_PROMPT = '0'; "
|
||||||
|
f"Set-Location (Split-Path {_ps_quote(workdir)} -Parent); "
|
||||||
|
f"git clone --depth 1 --branch {_ps_quote(clone_branch)}{sub_flag} "
|
||||||
|
f"{_ps_quote(clone_url)} (Split-Path {_ps_quote(workdir)} -Leaf)"
|
||||||
|
)
|
||||||
|
if clone_commit:
|
||||||
|
t.run(
|
||||||
|
f"git -C {_ps_quote(workdir)} checkout {_ps_quote(clone_commit)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise click.ClickException(
|
||||||
|
"Windows build requires either --host-source-dir or --clone-url."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Inject env vars + run the build.
|
||||||
|
env_setup = "; ".join(
|
||||||
|
f"$env:{k} = {_ps_quote(v)}" for k, v in extra_env.items()
|
||||||
|
)
|
||||||
|
if env_setup:
|
||||||
|
env_setup += "; "
|
||||||
|
if use_shared_cache:
|
||||||
|
env_setup += (
|
||||||
|
"$env:NUGET_PACKAGES = '\\\\vmware-host\\Shared Folders\\nuget-cache'; "
|
||||||
|
"$env:PIP_CACHE_DIR = '\\\\vmware-host\\Shared Folders\\pip-cache'; "
|
||||||
|
)
|
||||||
|
if build_command:
|
||||||
|
run_cmd = build_command
|
||||||
|
click.echo(f"[build run] custom build command: {run_cmd}")
|
||||||
|
else:
|
||||||
|
click.echo(
|
||||||
|
f"[build run] default build: dotnet restore + build (configuration={configuration})"
|
||||||
|
)
|
||||||
|
run_cmd = (
|
||||||
|
"dotnet restore; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; "
|
||||||
|
f"dotnet build --configuration {_ps_quote(configuration)} "
|
||||||
|
"--output (Join-Path (Get-Location) 'bin\\CIOutput')"
|
||||||
|
)
|
||||||
|
|
||||||
|
full_ps = (
|
||||||
|
f"{env_setup}Set-Location {_ps_quote(workdir)}; "
|
||||||
|
f"$ErrorActionPreference='Continue'; "
|
||||||
|
f"& cmd /c {_ps_quote(run_cmd + ' 2>&1')}; "
|
||||||
|
"exit $LASTEXITCODE"
|
||||||
|
)
|
||||||
|
result = t.run(full_ps, check=False)
|
||||||
|
if result.stdout:
|
||||||
|
sys.stdout.write(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
sys.stderr.write(result.stderr)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"build command failed (exit {result.returncode})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if skip_artifact:
|
||||||
|
click.echo("[build run] artifact packaging skipped (--skip-artifact).")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Package the artifact source into the requested zip on the guest.
|
||||||
|
if build_command:
|
||||||
|
src_rel = artifact_source or "dist"
|
||||||
|
src_expr = f"Join-Path {_ps_quote(workdir)} {_ps_quote(src_rel)}"
|
||||||
|
else:
|
||||||
|
src_expr = f"Join-Path {_ps_quote(workdir)} 'bin\\CIOutput'"
|
||||||
|
|
||||||
|
pkg_ps = (
|
||||||
|
f"$src = {src_expr}; "
|
||||||
|
f"$zip = {_ps_quote(artifact_zip)}; "
|
||||||
|
"if (-not (Test-Path $src)) { "
|
||||||
|
" Write-Host \"[build run] artifact source not found: $src\"; "
|
||||||
|
" exit 2 } ; "
|
||||||
|
"$dir = Split-Path $zip -Parent; "
|
||||||
|
"if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }; "
|
||||||
|
"if (Test-Path $zip) { Remove-Item $zip -Force }; "
|
||||||
|
"Compress-Archive -Path (Join-Path $src '*') -DestinationPath $zip -Force"
|
||||||
|
)
|
||||||
|
t.run(pkg_ps)
|
||||||
|
click.echo(f"[build run] artifact written to {artifact_zip}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- CLI
|
||||||
|
|
||||||
|
|
||||||
|
@build.command("run")
|
||||||
|
@click.option("--ip-address", "ip_address", required=True, help="Guest IP address.")
|
||||||
|
@click.option(
|
||||||
|
"--guest-os",
|
||||||
|
type=click.Choice(["windows", "linux"], case_sensitive=False),
|
||||||
|
default="windows",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--credential-target",
|
||||||
|
"credential_target",
|
||||||
|
default=None,
|
||||||
|
help="Keyring target for the guest user (Windows). Defaults to config.guest_cred_target.",
|
||||||
|
)
|
||||||
|
@click.option("--ssh-user", "ssh_user", default="ci_build", show_default=True)
|
||||||
|
@click.option(
|
||||||
|
"--ssh-key-path",
|
||||||
|
"ssh_key_path",
|
||||||
|
default=None,
|
||||||
|
help="SSH private key path (Linux). Defaults to config.ssh_key_path.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ssh-known-hosts-file",
|
||||||
|
"ssh_known_hosts_file",
|
||||||
|
default=None,
|
||||||
|
help="Persistent known_hosts file (Linux). Default: permissive (None).",
|
||||||
|
)
|
||||||
|
@click.option("--host-source-dir", "host_source_dir", default=None)
|
||||||
|
@click.option("--clone-url", "clone_url", default=None)
|
||||||
|
@click.option("--clone-branch", "clone_branch", default="main", show_default=True)
|
||||||
|
@click.option("--clone-commit", "clone_commit", default="")
|
||||||
|
@click.option("--clone-submodules", "clone_submodules", is_flag=True, default=False)
|
||||||
|
@click.option(
|
||||||
|
"--gitea-credential-target",
|
||||||
|
"gitea_credential_target",
|
||||||
|
default="GiteaPAT",
|
||||||
|
show_default=True,
|
||||||
|
help="(Reserved) Keyring target for Gitea PAT — currently unused by the Python port.",
|
||||||
|
)
|
||||||
|
@click.option("--build-command", "build_command", default="")
|
||||||
|
@click.option(
|
||||||
|
"--guest-work-dir",
|
||||||
|
"guest_work_dir",
|
||||||
|
default=None,
|
||||||
|
help="Workdir inside the guest (defaults: Win=C:\\CI\\build, Linux=/opt/ci/build).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--guest-artifact-zip",
|
||||||
|
"guest_artifact_zip",
|
||||||
|
default=None,
|
||||||
|
help="Artifact zip path inside the Windows guest (default: C:\\CI\\output\\artifacts.zip).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--guest-linux-work-dir",
|
||||||
|
"guest_linux_work_dir",
|
||||||
|
default=None,
|
||||||
|
help="Alias for --guest-work-dir, kept for shim compatibility (Linux).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--guest-linux-output-dir",
|
||||||
|
"guest_linux_output_dir",
|
||||||
|
default="/opt/ci/output",
|
||||||
|
show_default=True,
|
||||||
|
)
|
||||||
|
@click.option("--guest-artifact-source", "guest_artifact_source", default="dist", show_default=True)
|
||||||
|
@click.option("--configuration", default="Release", show_default=True)
|
||||||
|
@click.option("--skip-artifact", "skip_artifact", is_flag=True, default=False)
|
||||||
|
@click.option("--use-shared-cache", "use_shared_cache", is_flag=True, default=False)
|
||||||
|
@click.option(
|
||||||
|
"--extra-env",
|
||||||
|
"extra_env",
|
||||||
|
multiple=True,
|
||||||
|
help="Repeatable KEY=VALUE pairs injected into the guest environment.",
|
||||||
|
)
|
||||||
|
def build_run(
|
||||||
|
ip_address: str,
|
||||||
|
guest_os: str,
|
||||||
|
credential_target: str | None,
|
||||||
|
ssh_user: str,
|
||||||
|
ssh_key_path: str | None,
|
||||||
|
ssh_known_hosts_file: str | None,
|
||||||
|
host_source_dir: str | None,
|
||||||
|
clone_url: str | None,
|
||||||
|
clone_branch: str,
|
||||||
|
clone_commit: str,
|
||||||
|
clone_submodules: bool,
|
||||||
|
gitea_credential_target: str,
|
||||||
|
build_command: str,
|
||||||
|
guest_work_dir: str | None,
|
||||||
|
guest_artifact_zip: str | None,
|
||||||
|
guest_linux_work_dir: str | None,
|
||||||
|
guest_linux_output_dir: str,
|
||||||
|
guest_artifact_source: str,
|
||||||
|
configuration: str,
|
||||||
|
skip_artifact: bool,
|
||||||
|
use_shared_cache: bool,
|
||||||
|
extra_env: tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
"""Run a build inside a guest VM, optionally packaging the result.
|
||||||
|
|
||||||
|
Mirrors ``scripts/Invoke-RemoteBuild.ps1``. Source is provisioned via
|
||||||
|
one of:
|
||||||
|
|
||||||
|
* ``--host-source-dir`` — local directory zipped/tarred and uploaded;
|
||||||
|
* ``--clone-url`` — ``git clone`` invoked inside the guest.
|
||||||
|
|
||||||
|
Phase C hook: ``--guest-work-dir`` and ``--guest-artifact-zip`` are
|
||||||
|
parameterised; defaults are applied here, never inside the transport
|
||||||
|
layer.
|
||||||
|
"""
|
||||||
|
del gitea_credential_target # not yet honoured by Python port
|
||||||
|
guest_os = guest_os.lower()
|
||||||
|
|
||||||
|
if host_source_dir and clone_url:
|
||||||
|
raise click.BadParameter(
|
||||||
|
"Provide either --host-source-dir or --clone-url, not both."
|
||||||
|
)
|
||||||
|
if not host_source_dir and not clone_url:
|
||||||
|
raise click.BadParameter(
|
||||||
|
"Provide either --host-source-dir or --clone-url."
|
||||||
|
)
|
||||||
|
if host_source_dir and not Path(host_source_dir).is_dir():
|
||||||
|
raise click.BadParameter(f"--host-source-dir does not exist: {host_source_dir}")
|
||||||
|
|
||||||
|
extra = _parse_extra_env(extra_env)
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
if guest_os == "linux":
|
||||||
|
workdir = guest_linux_work_dir or guest_work_dir or "/opt/ci/build"
|
||||||
|
key_path: str | None = ssh_key_path
|
||||||
|
if key_path is None and config.ssh_key_path is not None:
|
||||||
|
key_path = str(config.ssh_key_path)
|
||||||
|
try:
|
||||||
|
_linux_build(
|
||||||
|
ip_address=ip_address,
|
||||||
|
ssh_user=ssh_user,
|
||||||
|
key_path=key_path,
|
||||||
|
known_hosts=ssh_known_hosts_file,
|
||||||
|
workdir=workdir,
|
||||||
|
output_dir=guest_linux_output_dir,
|
||||||
|
host_source_dir=host_source_dir,
|
||||||
|
clone_url=clone_url,
|
||||||
|
clone_branch=clone_branch,
|
||||||
|
clone_commit=clone_commit,
|
||||||
|
clone_submodules=clone_submodules,
|
||||||
|
build_command=build_command,
|
||||||
|
artifact_source=guest_artifact_source,
|
||||||
|
extra_env=extra,
|
||||||
|
skip_artifact=skip_artifact,
|
||||||
|
)
|
||||||
|
except (TransportError, TransportCommandError) as exc:
|
||||||
|
raise click.ClickException(str(exc)) from exc
|
||||||
|
else:
|
||||||
|
workdir = guest_work_dir or "C:\\CI\\build"
|
||||||
|
artifact_zip = guest_artifact_zip or "C:\\CI\\output\\artifacts.zip"
|
||||||
|
target = credential_target or config.guest_cred_target
|
||||||
|
try:
|
||||||
|
_windows_build(
|
||||||
|
ip_address=ip_address,
|
||||||
|
credential_target=target,
|
||||||
|
workdir=workdir,
|
||||||
|
artifact_zip=artifact_zip,
|
||||||
|
host_source_dir=host_source_dir,
|
||||||
|
clone_url=clone_url,
|
||||||
|
clone_branch=clone_branch,
|
||||||
|
clone_commit=clone_commit,
|
||||||
|
clone_submodules=clone_submodules,
|
||||||
|
build_command=build_command,
|
||||||
|
artifact_source=guest_artifact_source,
|
||||||
|
configuration=configuration,
|
||||||
|
extra_env=extra,
|
||||||
|
skip_artifact=skip_artifact,
|
||||||
|
use_shared_cache=use_shared_cache,
|
||||||
|
)
|
||||||
|
except (TransportError, TransportCommandError) as exc:
|
||||||
|
raise click.ClickException(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["build", "build_run"]
|
||||||
@@ -354,4 +354,116 @@ def vm_cleanup(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────── new
|
||||||
|
|
||||||
|
|
||||||
|
@vm.command("new")
|
||||||
|
@click.option(
|
||||||
|
"--template",
|
||||||
|
"--template-path",
|
||||||
|
"template",
|
||||||
|
required=True,
|
||||||
|
help="Identifier of the template VM (Workstation: VMX path; ESXi: managed-object ref).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--snapshot",
|
||||||
|
"--snapshot-name",
|
||||||
|
"snapshot",
|
||||||
|
default="BaseClean",
|
||||||
|
show_default=True,
|
||||||
|
help="Snapshot name on the template to clone from.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--clone-base-dir",
|
||||||
|
"clone_base_dir",
|
||||||
|
required=True,
|
||||||
|
help="Directory under which the new clone folder will be created.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--job-id",
|
||||||
|
"job_id",
|
||||||
|
required=True,
|
||||||
|
help="Unique job identifier (used to name the clone folder).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--vmrun-path",
|
||||||
|
"vmrun_path",
|
||||||
|
default=None,
|
||||||
|
help="Override vmrun binary path.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--guest-os",
|
||||||
|
type=click.Choice(["windows", "linux"], case_sensitive=False),
|
||||||
|
default="windows",
|
||||||
|
show_default=True,
|
||||||
|
help="Informational; reserved for backend hints in Phase C.",
|
||||||
|
)
|
||||||
|
def vm_new(
|
||||||
|
template: str,
|
||||||
|
snapshot: str,
|
||||||
|
clone_base_dir: str,
|
||||||
|
job_id: str,
|
||||||
|
vmrun_path: str | None,
|
||||||
|
guest_os: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a linked clone of the template VM for an ephemeral CI build.
|
||||||
|
|
||||||
|
Mirrors ``scripts/New-BuildVM.ps1``. Prints the clone identifier
|
||||||
|
(Workstation: VMX path) on stdout on success.
|
||||||
|
|
||||||
|
Phase C hook: ``--template`` is treated as an opaque string; the backend
|
||||||
|
is responsible for interpretation. Workstation builds the destination
|
||||||
|
path under ``--clone-base-dir`` from ``--job-id`` + timestamp.
|
||||||
|
"""
|
||||||
|
del guest_os # currently informational only
|
||||||
|
# Verify the template identifier looks plausible: for Workstation it is
|
||||||
|
# a VMX path. For other backends in Phase C this validation will move
|
||||||
|
# into the backend itself.
|
||||||
|
template_path = Path(template)
|
||||||
|
if not template_path.is_file():
|
||||||
|
raise click.ClickException(f"Template VMX not found: {template}")
|
||||||
|
|
||||||
|
base = Path(clone_base_dir)
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
|
||||||
|
clone_name = f"Clone_{job_id}_{timestamp}"
|
||||||
|
clone_dir = base / clone_name
|
||||||
|
clone_vmx = clone_dir / f"{clone_name}.vmx"
|
||||||
|
|
||||||
|
click.echo("[vm new] creating linked clone...")
|
||||||
|
click.echo(f" template : {template}")
|
||||||
|
click.echo(f" snapshot : {snapshot}")
|
||||||
|
click.echo(f" clone vmx: {clone_vmx}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend = _make_backend(vmrun_path)
|
||||||
|
except BackendNotAvailable as exc:
|
||||||
|
raise click.ClickException(f"vmrun unavailable: {exc}") from exc
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
try:
|
||||||
|
handle = backend.clone_linked(
|
||||||
|
template=str(template_path),
|
||||||
|
snapshot=snapshot,
|
||||||
|
name=clone_name,
|
||||||
|
destination=str(clone_vmx),
|
||||||
|
)
|
||||||
|
except BackendError as exc:
|
||||||
|
# Clean up partial clone directory if vmrun left one behind.
|
||||||
|
if clone_dir.exists():
|
||||||
|
_try_remove_dir(clone_dir)
|
||||||
|
raise click.ClickException(f"clone failed: {exc}") from exc
|
||||||
|
|
||||||
|
if not clone_vmx.is_file():
|
||||||
|
raise click.ClickException(
|
||||||
|
f"backend reported success but clone VMX is missing: {clone_vmx}"
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
click.echo(f"[vm new] clone created in {elapsed:.1f}s")
|
||||||
|
# Final stdout line: the identifier itself, so PS callers can capture it.
|
||||||
|
click.echo(handle.identifier)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["cleanup_orphans", "vm"]
|
__all__ = ["cleanup_orphans", "vm"]
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Pester v5 tests for scripts/New-BuildVM.ps1
|
|
||||||
#>
|
|
||||||
|
|
||||||
BeforeAll {
|
|
||||||
$script:ScriptPath = Join-Path $PSScriptRoot '..\scripts\New-BuildVM.ps1'
|
|
||||||
$script:CloneBaseDir = Join-Path $env:TEMP "CI-Tests-CloneBase-$PID"
|
|
||||||
New-Item -ItemType Directory -Path $script:CloneBaseDir -Force | Out-Null
|
|
||||||
|
|
||||||
# Fake vmrun.ps1 — exits with $env:FAKE_VMRUN_EXIT (default 0)
|
|
||||||
# On success it also creates the destination VMX so the post-clone Test-Path passes.
|
|
||||||
# Using .ps1 instead of .cmd avoids Windows cmd redirect limitations inside if blocks.
|
|
||||||
$script:FakeVmrun = Join-Path $env:TEMP "fake-vmrun-$PID.ps1"
|
|
||||||
Set-Content $script:FakeVmrun -Value @'
|
|
||||||
# Args: -T ws clone <template> <dst_vmx> linked -snapshot <snapshot>
|
|
||||||
$exitCode = if ($env:FAKE_VMRUN_EXIT) { [int]$env:FAKE_VMRUN_EXIT } else { 0 }
|
|
||||||
if ($exitCode -eq 0) {
|
|
||||||
# Create the destination VMX (5th positional arg) so Test-Path in caller passes.
|
|
||||||
# Real vmrun creates the clone dir+file; we must do the same here.
|
|
||||||
$dstVmx = $args[4] # -T ws clone <tmpl> <dst> ...
|
|
||||||
$dstDir = Split-Path $dstVmx -Parent
|
|
||||||
if (-not (Test-Path $dstDir)) { New-Item -ItemType Directory -Path $dstDir -Force | Out-Null }
|
|
||||||
Set-Content $dstVmx ''
|
|
||||||
}
|
|
||||||
exit $exitCode
|
|
||||||
'@
|
|
||||||
|
|
||||||
# Fake template VMX — just needs to exist
|
|
||||||
$script:FakeTemplate = Join-Path $env:TEMP "fake-template-$PID.vmx"
|
|
||||||
Set-Content $script:FakeTemplate -Value 'config.version = "8"'
|
|
||||||
}
|
|
||||||
|
|
||||||
AfterAll {
|
|
||||||
Remove-Item $script:CloneBaseDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item $script:FakeVmrun -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item $script:FakeTemplate -Force -ErrorAction SilentlyContinue
|
|
||||||
$env:FAKE_VMRUN_EXIT = $null
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'New-BuildVM — parameter validation' {
|
|
||||||
It 'throws when TemplatePath does not exist' {
|
|
||||||
{
|
|
||||||
& $script:ScriptPath `
|
|
||||||
-TemplatePath 'C:\DoesNotExist\template.vmx' `
|
|
||||||
-CloneBaseDir $script:CloneBaseDir `
|
|
||||||
-JobId 'test-job-1' `
|
|
||||||
-VmrunPath $script:FakeVmrun
|
|
||||||
} | Should -Throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'New-BuildVM — vmrun failure' {
|
|
||||||
It 'throws and cleans up clone dir when vmrun clone exits non-zero' {
|
|
||||||
$env:FAKE_VMRUN_EXIT = '1'
|
|
||||||
{
|
|
||||||
& $script:ScriptPath `
|
|
||||||
-TemplatePath $script:FakeTemplate `
|
|
||||||
-CloneBaseDir $script:CloneBaseDir `
|
|
||||||
-JobId 'test-job-fail' `
|
|
||||||
-VmrunPath $script:FakeVmrun
|
|
||||||
} | Should -Throw -ExpectedMessage '*clone failed*'
|
|
||||||
|
|
||||||
# No leftover clone dir
|
|
||||||
$leftover = Get-ChildItem $script:CloneBaseDir -Directory |
|
|
||||||
Where-Object { $_.Name -like '*test-job-fail*' }
|
|
||||||
$leftover | Should -BeNullOrEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
AfterEach { $env:FAKE_VMRUN_EXIT = $null }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'New-BuildVM — clone name format' {
|
|
||||||
It 'clone folder is named Clone_{JobId}_{yyyyMMdd_HHmmss}' {
|
|
||||||
$env:FAKE_VMRUN_EXIT = '0'
|
|
||||||
$vmx = & $script:ScriptPath `
|
|
||||||
-TemplatePath $script:FakeTemplate `
|
|
||||||
-CloneBaseDir $script:CloneBaseDir `
|
|
||||||
-JobId 'run-42' `
|
|
||||||
-VmrunPath $script:FakeVmrun
|
|
||||||
|
|
||||||
$vmx | Should -Match 'Clone_run-42_\d{8}_\d{6}'
|
|
||||||
}
|
|
||||||
|
|
||||||
It 'returns a string ending in .vmx' {
|
|
||||||
$env:FAKE_VMRUN_EXIT = '0'
|
|
||||||
$vmx = & $script:ScriptPath `
|
|
||||||
-TemplatePath $script:FakeTemplate `
|
|
||||||
-CloneBaseDir $script:CloneBaseDir `
|
|
||||||
-JobId 'run-99' `
|
|
||||||
-VmrunPath $script:FakeVmrun
|
|
||||||
|
|
||||||
$vmx | Should -Match '\.vmx$'
|
|
||||||
}
|
|
||||||
|
|
||||||
AfterEach {
|
|
||||||
$env:FAKE_VMRUN_EXIT = $null
|
|
||||||
# Clean up clones created by successful tests
|
|
||||||
Get-ChildItem $script:CloneBaseDir -Directory -ErrorAction SilentlyContinue |
|
|
||||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Pester v5 tests for scripts/Remove-BuildVM.ps1
|
|
||||||
#>
|
|
||||||
|
|
||||||
BeforeAll {
|
|
||||||
$script:ScriptPath = Join-Path $PSScriptRoot '..\scripts\Remove-BuildVM.ps1'
|
|
||||||
|
|
||||||
# Fake vmrun: accepts any args, always exits 0 (best-effort ops don't need to fail here)
|
|
||||||
$script:FakeVmrun = Join-Path $env:TEMP "fake-vmrun-remove-$PID.cmd"
|
|
||||||
Set-Content $script:FakeVmrun -Value '@echo off & exit /b 0'
|
|
||||||
}
|
|
||||||
|
|
||||||
AfterAll {
|
|
||||||
Remove-Item $script:FakeVmrun -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'Remove-BuildVM — missing VMX' {
|
|
||||||
It 'returns without error when VMX does not exist' {
|
|
||||||
{ & $script:ScriptPath -VMPath 'C:\DoesNotExist\clone.vmx' -VmrunPath $script:FakeVmrun } |
|
|
||||||
Should -Not -Throw
|
|
||||||
}
|
|
||||||
|
|
||||||
It 'removes partial clone directory even when VMX is missing' {
|
|
||||||
$partialDir = Join-Path $env:TEMP "partial-clone-$PID"
|
|
||||||
New-Item -ItemType Directory -Path $partialDir -Force | Out-Null
|
|
||||||
$fakeMissingVmx = Join-Path $partialDir 'missing.vmx'
|
|
||||||
|
|
||||||
& $script:ScriptPath -VMPath $fakeMissingVmx -VmrunPath $script:FakeVmrun
|
|
||||||
|
|
||||||
Test-Path $partialDir | Should -Be $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'Remove-BuildVM — WhatIf' {
|
|
||||||
It 'does not remove clone directory when -WhatIf is passed' {
|
|
||||||
$cloneDir = Join-Path $env:TEMP "whatif-clone-$PID"
|
|
||||||
$cloneVmx = Join-Path $cloneDir 'whatif-clone.vmx'
|
|
||||||
New-Item -ItemType Directory -Path $cloneDir -Force | Out-Null
|
|
||||||
Set-Content $cloneVmx -Value 'config.version = "8"'
|
|
||||||
|
|
||||||
& $script:ScriptPath -VMPath $cloneVmx -VmrunPath $script:FakeVmrun -WhatIf
|
|
||||||
|
|
||||||
Test-Path $cloneDir | Should -Be $true
|
|
||||||
|
|
||||||
Remove-Item $cloneDir -Recurse -Force -ErrorAction SilentlyContinue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'Remove-BuildVM — successful destroy' {
|
|
||||||
It 'removes the clone directory when VMX exists and vmrun succeeds' {
|
|
||||||
$cloneDir = Join-Path $env:TEMP "live-clone-$PID"
|
|
||||||
$cloneVmx = Join-Path $cloneDir 'live-clone.vmx'
|
|
||||||
New-Item -ItemType Directory -Path $cloneDir -Force | Out-Null
|
|
||||||
Set-Content $cloneVmx -Value 'config.version = "8"'
|
|
||||||
|
|
||||||
& $script:ScriptPath `
|
|
||||||
-VMPath $cloneVmx `
|
|
||||||
-VmrunPath $script:FakeVmrun `
|
|
||||||
-GracefulTimeoutSeconds 1 # short timeout for test speed
|
|
||||||
|
|
||||||
Test-Path $cloneDir | Should -Be $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#Requires -Version 5.1
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Pester v5 tests for scripts/Wait-VMReady.ps1
|
|
||||||
#>
|
|
||||||
|
|
||||||
BeforeAll {
|
|
||||||
$script:ScriptPath = Join-Path $PSScriptRoot '..\scripts\Wait-VMReady.ps1'
|
|
||||||
|
|
||||||
# Fake vmrun: getGuestIPAddress exits with %FAKE_VMRUN_EXIT% (default 1 = not ready)
|
|
||||||
$script:FakeVmrun = Join-Path $env:TEMP "fake-vmrun-wait-$PID.cmd"
|
|
||||||
Set-Content $script:FakeVmrun -Value @'
|
|
||||||
@echo off
|
|
||||||
if "%FAKE_VMRUN_EXIT%"=="" ( exit /b 1 ) else ( exit /b %FAKE_VMRUN_EXIT% )
|
|
||||||
'@
|
|
||||||
|
|
||||||
# Fake VMX file — just needs to exist
|
|
||||||
$script:FakeVmx = Join-Path $env:TEMP "fake-wait-$PID.vmx"
|
|
||||||
Set-Content $script:FakeVmx -Value 'config.version = "8"'
|
|
||||||
}
|
|
||||||
|
|
||||||
AfterAll {
|
|
||||||
Remove-Item $script:FakeVmrun -Force -ErrorAction SilentlyContinue
|
|
||||||
Remove-Item $script:FakeVmx -Force -ErrorAction SilentlyContinue
|
|
||||||
$env:FAKE_VMRUN_EXIT = $null
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'Wait-VMReady — missing vmrun' {
|
|
||||||
It 'throws when VmrunPath does not exist' {
|
|
||||||
{
|
|
||||||
& $script:ScriptPath `
|
|
||||||
-VMPath $script:FakeVmx `
|
|
||||||
-IPAddress '192.168.79.101' `
|
|
||||||
-VmrunPath 'C:\DoesNotExist\vmrun.exe'
|
|
||||||
} | Should -Throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'Wait-VMReady — timeout' {
|
|
||||||
It 'throws with timeout message when VM never becomes ready' {
|
|
||||||
$env:FAKE_VMRUN_EXIT = '1' # vmrun getGuestIPAddress always fails → VM not running
|
|
||||||
{
|
|
||||||
& $script:ScriptPath `
|
|
||||||
-VMPath $script:FakeVmx `
|
|
||||||
-IPAddress '192.168.79.101' `
|
|
||||||
-VmrunPath $script:FakeVmrun `
|
|
||||||
-TimeoutSeconds 5 `
|
|
||||||
-PollIntervalSeconds 2 `
|
|
||||||
-SkipPing
|
|
||||||
} | Should -Throw -ExpectedMessage '*Timed out*'
|
|
||||||
}
|
|
||||||
|
|
||||||
AfterEach { $env:FAKE_VMRUN_EXIT = $null }
|
|
||||||
}
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
Describe 'Wait-VMReady — IP validation' {
|
|
||||||
It 'rejects an invalid IP address' {
|
|
||||||
{
|
|
||||||
& $script:ScriptPath `
|
|
||||||
-VMPath $script:FakeVmx `
|
|
||||||
-IPAddress '999.999.999.999' `
|
|
||||||
-VmrunPath $script:FakeVmrun
|
|
||||||
} | Should -Throw
|
|
||||||
}
|
|
||||||
|
|
||||||
It 'accepts a valid IP address without immediate error from parameter binding' {
|
|
||||||
# This will still time out (vmrun fake exits 1), but the IP itself must not
|
|
||||||
# cause a parameter validation error — that would throw before the timeout.
|
|
||||||
$env:FAKE_VMRUN_EXIT = '1'
|
|
||||||
$err = $null
|
|
||||||
try {
|
|
||||||
& $script:ScriptPath `
|
|
||||||
-VMPath $script:FakeVmx `
|
|
||||||
-IPAddress '192.168.79.101' `
|
|
||||||
-VmrunPath $script:FakeVmrun `
|
|
||||||
-TimeoutSeconds 3 `
|
|
||||||
-PollIntervalSeconds 2 `
|
|
||||||
-SkipPing `
|
|
||||||
-ErrorAction Stop
|
|
||||||
} catch {
|
|
||||||
$err = $_
|
|
||||||
}
|
|
||||||
# Error must be the timeout, not a parameter validation error
|
|
||||||
"$err" | Should -Match 'Timed out'
|
|
||||||
"$err" | Should -Not -Match 'Cannot validate'
|
|
||||||
|
|
||||||
$env:FAKE_VMRUN_EXIT = $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""Tests for ``ci_orchestrator artifacts collect``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
import ci_orchestrator.commands.artifacts as artifacts_module
|
||||||
|
from ci_orchestrator.__main__ import cli
|
||||||
|
from ci_orchestrator.credentials import Credential
|
||||||
|
from ci_orchestrator.transport.errors import TransportCommandError
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, stdout: str = "OK", returncode: int = 0) -> None:
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = ""
|
||||||
|
self.returncode = returncode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTransport:
|
||||||
|
instances: ClassVar[list[_FakeTransport]] = []
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self.runs: list[str] = []
|
||||||
|
self.fetched: list[tuple[str, str]] = []
|
||||||
|
self.fetch_payloads: dict[str, bytes] = {}
|
||||||
|
self.run_overrides: dict[str, _FakeResult] = {}
|
||||||
|
type(self).instances.append(self)
|
||||||
|
|
||||||
|
def __enter__(self) -> _FakeTransport:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_e: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self, script: str, *, check: bool = True) -> _FakeResult:
|
||||||
|
self.runs.append(script)
|
||||||
|
for key, override in self.run_overrides.items():
|
||||||
|
if key in script:
|
||||||
|
if check and not override.ok:
|
||||||
|
raise TransportCommandError(
|
||||||
|
override.returncode, override.stdout, override.stderr
|
||||||
|
)
|
||||||
|
return override
|
||||||
|
return _FakeResult()
|
||||||
|
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
self.fetched.append((remote_path, str(local_path)))
|
||||||
|
payload = self.fetch_payloads.get(remote_path, b"data")
|
||||||
|
Path(local_path).write_bytes(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_instances() -> None:
|
||||||
|
_FakeTransport.instances = []
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
class _Store:
|
||||||
|
def get(self, _t: str) -> Credential:
|
||||||
|
return Credential("ci", "pwd")
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _FakeTransport)
|
||||||
|
monkeypatch.setattr(artifacts_module, "SshTransport", _FakeTransport)
|
||||||
|
monkeypatch.setattr(artifacts_module, "KeyringCredentialStore", lambda: _Store())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Windows happy path ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_windows_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
out_dir = tmp_path / "artifacts"
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts",
|
||||||
|
"collect",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.7",
|
||||||
|
"--guest-artifact-path",
|
||||||
|
"C:\\CI\\output\\artifacts.zip",
|
||||||
|
"--host-artifact-dir",
|
||||||
|
str(out_dir),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert (out_dir / "artifacts.zip").is_file()
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
assert any("Test-Path" in c for c in instance.runs)
|
||||||
|
assert instance.fetched == [
|
||||||
|
("C:\\CI\\output\\artifacts.zip", str(out_dir / "artifacts.zip"))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_windows_artifact_missing(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
class _Missing(_FakeTransport):
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
self.run_overrides["Test-Path"] = _FakeResult(stdout="MISSING\r\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _Missing)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts",
|
||||||
|
"collect",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.7",
|
||||||
|
"--guest-artifact-path",
|
||||||
|
"C:\\CI\\output\\artifacts.zip",
|
||||||
|
"--host-artifact-dir",
|
||||||
|
str(tmp_path / "out"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "artifact not found" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_windows_include_logs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
class _WithLogs(_FakeTransport):
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
self.run_overrides["Get-ChildItem"] = _FakeResult(
|
||||||
|
stdout="C:\\CI\\build\\one.log\r\nC:\\CI\\build\\sub\\two.log\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "WinRmTransport", _WithLogs)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts",
|
||||||
|
"collect",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.7",
|
||||||
|
"--guest-artifact-path",
|
||||||
|
"C:\\CI\\output\\artifacts.zip",
|
||||||
|
"--host-artifact-dir",
|
||||||
|
str(tmp_path / "out"),
|
||||||
|
"--include-logs",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
fetched_remotes = [r for r, _ in _FakeTransport.instances[0].fetched]
|
||||||
|
assert "C:\\CI\\build\\one.log" in fetched_remotes
|
||||||
|
assert "C:\\CI\\build\\sub\\two.log" in fetched_remotes
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_writes_manifest_when_job_id_set(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
out = tmp_path / "out"
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts",
|
||||||
|
"collect",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.7",
|
||||||
|
"--guest-artifact-path",
|
||||||
|
"C:\\CI\\output\\artifacts.zip",
|
||||||
|
"--host-artifact-dir",
|
||||||
|
str(out),
|
||||||
|
"--job-id",
|
||||||
|
"run-42",
|
||||||
|
"--commit",
|
||||||
|
"abc1234",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
manifest_path = out / "manifest.json"
|
||||||
|
assert manifest_path.is_file()
|
||||||
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
assert data["jobId"] == "run-42"
|
||||||
|
assert data["commit"] == "abc1234"
|
||||||
|
names = [f["name"] for f in data["files"]]
|
||||||
|
assert "artifacts.zip" in names
|
||||||
|
|
||||||
|
|
||||||
|
# ── Linux happy path ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_linux_extracts_tarball(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
# Build a real tarball that the fake transport will "fetch".
|
||||||
|
payload_dir = tmp_path / "payload"
|
||||||
|
payload_dir.mkdir()
|
||||||
|
(payload_dir / "binary.bin").write_bytes(b"\x01\x02\x03")
|
||||||
|
(payload_dir / "log.txt").write_text("hello", encoding="utf-8")
|
||||||
|
tar_local = tmp_path / "src.tar.gz"
|
||||||
|
with tarfile.open(tar_local, "w:gz") as tf:
|
||||||
|
for f in payload_dir.iterdir():
|
||||||
|
tf.add(f, arcname=f.name)
|
||||||
|
payload_bytes = tar_local.read_bytes()
|
||||||
|
|
||||||
|
class _LinuxTransport(_FakeTransport):
|
||||||
|
def __init__(self, *a: Any, **kw: Any) -> None:
|
||||||
|
super().__init__(*a, **kw)
|
||||||
|
# Any fetched path returns the same tarball payload.
|
||||||
|
self.fetch_payloads["__default__"] = payload_bytes
|
||||||
|
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
self.fetched.append((remote_path, str(local_path)))
|
||||||
|
Path(local_path).write_bytes(payload_bytes)
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "SshTransport", _LinuxTransport)
|
||||||
|
|
||||||
|
out = tmp_path / "out"
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts",
|
||||||
|
"collect",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--guest-artifact-path",
|
||||||
|
"/opt/ci/output",
|
||||||
|
"--host-artifact-dir",
|
||||||
|
str(out),
|
||||||
|
"--ssh-key-path",
|
||||||
|
str(tmp_path / "key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert (out / "binary.bin").read_bytes() == b"\x01\x02\x03"
|
||||||
|
assert (out / "log.txt").read_text(encoding="utf-8") == "hello"
|
||||||
|
cmds = _FakeTransport.instances[0].runs
|
||||||
|
assert any("tar -czf" in c for c in cmds)
|
||||||
|
# Cleanup of the temp tarball on the guest was attempted.
|
||||||
|
assert any("rm -f" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_collect_linux_no_files_raises(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
|
||||||
|
# Empty tarball => extraction yields nothing.
|
||||||
|
empty_tar = tmp_path / "empty.tar.gz"
|
||||||
|
with tarfile.open(empty_tar, "w:gz"):
|
||||||
|
pass
|
||||||
|
payload = empty_tar.read_bytes()
|
||||||
|
|
||||||
|
class _Empty(_FakeTransport):
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
self.fetched.append((remote_path, str(local_path)))
|
||||||
|
Path(local_path).write_bytes(payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(artifacts_module, "SshTransport", _Empty)
|
||||||
|
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"artifacts",
|
||||||
|
"collect",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--guest-artifact-path",
|
||||||
|
"/opt/ci/output",
|
||||||
|
"--host-artifact-dir",
|
||||||
|
str(tmp_path / "out"),
|
||||||
|
"--ssh-key-path",
|
||||||
|
str(tmp_path / "key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "no files were transferred" in result.output
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Tests for ``ci_orchestrator build run``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
import ci_orchestrator.commands.build as build_module
|
||||||
|
from ci_orchestrator.__main__ import cli
|
||||||
|
from ci_orchestrator.credentials import Credential
|
||||||
|
from ci_orchestrator.transport.errors import TransportCommandError
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResult:
|
||||||
|
def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0) -> None:
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
self.returncode = returncode
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeTransport:
|
||||||
|
"""Records every ``run/copy/fetch`` call."""
|
||||||
|
|
||||||
|
instances: ClassVar[list[_FakeTransport]] = []
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.runs: list[tuple[str, bool]] = []
|
||||||
|
self.copies: list[tuple[str, str]] = []
|
||||||
|
self.fetches: list[tuple[str, str]] = []
|
||||||
|
self.run_results: dict[int, _FakeResult] = {}
|
||||||
|
self.default_result = _FakeResult(stdout="ok")
|
||||||
|
type(self).instances.append(self)
|
||||||
|
|
||||||
|
def __enter__(self) -> _FakeTransport:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_e: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self, script: str, *, check: bool = True) -> _FakeResult:
|
||||||
|
self.runs.append((script, check))
|
||||||
|
result = self.run_results.get(len(self.runs) - 1, self.default_result)
|
||||||
|
if check and not result.ok:
|
||||||
|
raise TransportCommandError(result.returncode, result.stdout, result.stderr)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def copy(self, local_path: str, remote_path: str) -> None:
|
||||||
|
self.copies.append((str(local_path), remote_path))
|
||||||
|
|
||||||
|
def fetch(self, remote_path: str, local_path: str) -> None:
|
||||||
|
self.fetches.append((remote_path, str(local_path)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_instances() -> None:
|
||||||
|
_FakeTransport.instances = []
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(monkeypatch: pytest.MonkeyPatch) -> type[_FakeTransport]:
|
||||||
|
class _Store:
|
||||||
|
def get(self, _t: str) -> Credential:
|
||||||
|
return Credential("ci", "pwd")
|
||||||
|
|
||||||
|
monkeypatch.setattr(build_module, "WinRmTransport", _FakeTransport)
|
||||||
|
monkeypatch.setattr(build_module, "SshTransport", _FakeTransport)
|
||||||
|
monkeypatch.setattr(build_module, "KeyringCredentialStore", lambda: _Store())
|
||||||
|
return _FakeTransport
|
||||||
|
|
||||||
|
|
||||||
|
# ── parameter validation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_requires_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli, ["build", "run", "--ip-address", "10.0.0.1"]
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "either --host-source-dir or --clone-url" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_rejects_both_modes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.1",
|
||||||
|
"--host-source-dir",
|
||||||
|
str(src),
|
||||||
|
"--clone-url",
|
||||||
|
"https://example/repo.git",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "not both" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_rejects_missing_source_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.1",
|
||||||
|
"--host-source-dir",
|
||||||
|
str(tmp_path / "ghost"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "does not exist" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_rejects_bad_extra_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.1",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--host-source-dir",
|
||||||
|
str(src),
|
||||||
|
"--build-command",
|
||||||
|
"make",
|
||||||
|
"--extra-env",
|
||||||
|
"NO_EQUALS",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "KEY=VALUE" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── happy path: Linux ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_linux_clone_url_happy_path(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--clone-url",
|
||||||
|
"https://example/repo.git",
|
||||||
|
"--clone-branch",
|
||||||
|
"feature/x",
|
||||||
|
"--clone-commit",
|
||||||
|
"deadbeef",
|
||||||
|
"--clone-submodules",
|
||||||
|
"--build-command",
|
||||||
|
"make all",
|
||||||
|
"--guest-linux-work-dir",
|
||||||
|
"/work",
|
||||||
|
"--guest-linux-output-dir",
|
||||||
|
"/out",
|
||||||
|
"--guest-artifact-source",
|
||||||
|
"build",
|
||||||
|
"--extra-env",
|
||||||
|
"FOO=bar baz",
|
||||||
|
"--extra-env",
|
||||||
|
"TOKEN=secret",
|
||||||
|
"--ssh-key-path",
|
||||||
|
str(tmp_path / "key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
cmds = [c[0] for c in instance.runs]
|
||||||
|
assert any("rm -rf /work && mkdir -p /work" in c for c in cmds)
|
||||||
|
assert any("git clone --depth 1 --branch feature/x" in c for c in cmds)
|
||||||
|
assert any("--recurse-submodules" in c for c in cmds)
|
||||||
|
assert any("checkout deadbeef" in c for c in cmds)
|
||||||
|
# extra-env exported before build
|
||||||
|
assert any("export FOO='bar baz'" in c and "export TOKEN=secret" in c for c in cmds)
|
||||||
|
assert any("cd /work && make all" in c for c in cmds)
|
||||||
|
# artifact stage uses the requested source dir
|
||||||
|
assert any("/work/build" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_linux_host_source_dir(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "hello.txt").write_text("hi", encoding="utf-8")
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--host-source-dir",
|
||||||
|
str(src),
|
||||||
|
"--build-command",
|
||||||
|
"true",
|
||||||
|
"--ssh-key-path",
|
||||||
|
str(tmp_path / "key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
# A single tarball was uploaded.
|
||||||
|
assert len(instance.copies) == 1
|
||||||
|
local, remote = instance.copies[0]
|
||||||
|
assert local.endswith(".tar.gz")
|
||||||
|
assert remote.startswith("/tmp/")
|
||||||
|
# Extraction command was invoked.
|
||||||
|
assert any("tar -xzf" in c[0] for c in instance.runs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_linux_skip_artifact(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--clone-url",
|
||||||
|
"https://example/repo.git",
|
||||||
|
"--build-command",
|
||||||
|
"make",
|
||||||
|
"--skip-artifact",
|
||||||
|
"--ssh-key-path",
|
||||||
|
str(tmp_path / "key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
cmds = [c[0] for c in _FakeTransport.instances[0].runs]
|
||||||
|
# No staging command should run.
|
||||||
|
assert not any("/opt/ci/output" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_linux_build_failure_exits_nonzero(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
# Make the build command (4th run: rm/git clone/checkout-skipped/build)
|
||||||
|
# return non-zero. Index of build run varies; easiest: make any non-zero
|
||||||
|
# result for the run whose script contains 'cd '.
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
|
||||||
|
class _BuildFails(_FakeTransport):
|
||||||
|
def run(self, script: str, *, check: bool = True) -> _FakeResult:
|
||||||
|
if "cd /opt/ci/build && make" in script:
|
||||||
|
self.runs.append((script, check))
|
||||||
|
return _FakeResult(stdout="boom\n", stderr="err\n", returncode=2)
|
||||||
|
return super().run(script, check=check)
|
||||||
|
|
||||||
|
monkeypatch.setattr(build_module, "SshTransport", _BuildFails)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.5",
|
||||||
|
"--guest-os",
|
||||||
|
"linux",
|
||||||
|
"--host-source-dir",
|
||||||
|
str(src),
|
||||||
|
"--ssh-key-path",
|
||||||
|
str(tmp_path / "key"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "build command failed (exit 2)" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── happy path: Windows ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_windows_clone_url_default_dotnet(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.7",
|
||||||
|
"--guest-os",
|
||||||
|
"windows",
|
||||||
|
"--clone-url",
|
||||||
|
"https://example/repo.git",
|
||||||
|
"--use-shared-cache",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
cmds = [c[0] for c in _FakeTransport.instances[0].runs]
|
||||||
|
assert any("git clone --depth 1" in c for c in cmds)
|
||||||
|
assert any("dotnet restore" in c for c in cmds)
|
||||||
|
assert any("NUGET_PACKAGES" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_run_windows_host_source_dir(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
_patch(monkeypatch)
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "a.cs").write_text("//", encoding="utf-8")
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"build",
|
||||||
|
"run",
|
||||||
|
"--ip-address",
|
||||||
|
"10.0.0.7",
|
||||||
|
"--host-source-dir",
|
||||||
|
str(src),
|
||||||
|
"--build-command",
|
||||||
|
"echo built > dist\\out.txt",
|
||||||
|
"--guest-artifact-source",
|
||||||
|
"dist",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
instance = _FakeTransport.instances[0]
|
||||||
|
assert len(instance.copies) == 1
|
||||||
|
assert instance.copies[0][1] == "C:\\CI\\src-transfer.zip"
|
||||||
|
cmds = [c[0] for c in instance.runs]
|
||||||
|
assert any("Expand-Archive" in c for c in cmds)
|
||||||
|
# Packaging step references the configured artifact source dir.
|
||||||
|
assert any("Compress-Archive" in c and "'dist'" in c for c in cmds)
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Tests for ``ci_orchestrator vm new``.
|
||||||
|
|
||||||
|
Migrated from Pester ``tests/New-BuildVM.Tests.ps1``. Preserves:
|
||||||
|
|
||||||
|
* parameter validation: missing template VMX rejected
|
||||||
|
* failure path: backend clone failure cleans up the partial clone dir
|
||||||
|
* clone naming: ``Clone_{JobId}_{yyyyMMdd_HHmmss}``
|
||||||
|
* output: stdout final line is the clone VMX path
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
import ci_orchestrator.commands.vm as vm_module
|
||||||
|
from ci_orchestrator.__main__ import cli
|
||||||
|
from ci_orchestrator.backends.errors import BackendOperationFailed
|
||||||
|
from ci_orchestrator.backends.protocol import VmHandle
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeBackend:
|
||||||
|
"""Records calls and creates the destination VMX on success."""
|
||||||
|
|
||||||
|
def __init__(self, *, fail: bool = False) -> None:
|
||||||
|
self.fail = fail
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def clone_linked(
|
||||||
|
self,
|
||||||
|
template: str,
|
||||||
|
snapshot: str,
|
||||||
|
name: str,
|
||||||
|
destination: str | None = None,
|
||||||
|
) -> VmHandle:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"template": template,
|
||||||
|
"snapshot": snapshot,
|
||||||
|
"name": name,
|
||||||
|
"destination": destination,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if self.fail:
|
||||||
|
raise BackendOperationFailed("clone", 1, "Error: source not found")
|
||||||
|
# Real vmrun creates dest dir + VMX file. Mirror that here so the
|
||||||
|
# post-clone existence check passes.
|
||||||
|
assert destination is not None
|
||||||
|
dst = Path(destination)
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst.write_text("config.version = \"8\"", encoding="utf-8")
|
||||||
|
return VmHandle(identifier=str(dst), name=name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_template(tmp_path: Path) -> Path:
|
||||||
|
p = tmp_path / "WinBuild2025.vmx"
|
||||||
|
p.write_text("config.version = \"8\"", encoding="utf-8")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_rejects_missing_template(tmp_path: Path) -> None:
|
||||||
|
"""Pester migration: throws when TemplatePath does not exist."""
|
||||||
|
missing = tmp_path / "ghost.vmx"
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"new",
|
||||||
|
"--template",
|
||||||
|
str(missing),
|
||||||
|
"--clone-base-dir",
|
||||||
|
str(base),
|
||||||
|
"--job-id",
|
||||||
|
"test-1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Template VMX not found" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_clone_failure_cleans_up_partial_dir(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Pester migration: vmrun failure removes the partially-created clone dir."""
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
backend = _FakeBackend(fail=True)
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"new",
|
||||||
|
"--template",
|
||||||
|
str(fake_template),
|
||||||
|
"--clone-base-dir",
|
||||||
|
str(base),
|
||||||
|
"--job-id",
|
||||||
|
"test-fail",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "clone failed" in result.output
|
||||||
|
leftovers = [p for p in base.iterdir() if p.is_dir() and "test-fail" in p.name]
|
||||||
|
assert leftovers == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_clone_name_format_and_stdout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Pester migration: clone folder is ``Clone_{JobId}_{yyyyMMdd_HHmmss}``
|
||||||
|
and the final stdout line is the clone VMX path."""
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
backend = _FakeBackend()
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"new",
|
||||||
|
"--template",
|
||||||
|
str(fake_template),
|
||||||
|
"--clone-base-dir",
|
||||||
|
str(base),
|
||||||
|
"--job-id",
|
||||||
|
"run-42",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
final = result.output.strip().splitlines()[-1]
|
||||||
|
assert final.endswith(".vmx")
|
||||||
|
assert "Clone_run-42_" in final
|
||||||
|
# Format check: timestamp is 8 digits + underscore + 6 digits.
|
||||||
|
import re
|
||||||
|
|
||||||
|
assert re.search(r"Clone_run-42_\d{8}_\d{6}\.vmx$", final)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_passes_snapshot_default(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
backend = _FakeBackend()
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"new",
|
||||||
|
"--template",
|
||||||
|
str(fake_template),
|
||||||
|
"--clone-base-dir",
|
||||||
|
str(base),
|
||||||
|
"--job-id",
|
||||||
|
"run-99",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert backend.calls[0]["snapshot"] == "BaseClean"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_pascal_case_aliases(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""The PS-shim translation produces ``--template-path`` / ``--snapshot-name``."""
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
backend = _FakeBackend()
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"new",
|
||||||
|
"--template-path",
|
||||||
|
str(fake_template),
|
||||||
|
"--snapshot-name",
|
||||||
|
"Custom",
|
||||||
|
"--clone-base-dir",
|
||||||
|
str(base),
|
||||||
|
"--job-id",
|
||||||
|
"alias",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert backend.calls[0]["snapshot"] == "Custom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_creates_base_dir(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
base = tmp_path / "missing-base"
|
||||||
|
backend = _FakeBackend()
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm",
|
||||||
|
"new",
|
||||||
|
"--template",
|
||||||
|
str(fake_template),
|
||||||
|
"--clone-base-dir",
|
||||||
|
str(base),
|
||||||
|
"--job-id",
|
||||||
|
"bd",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert base.is_dir()
|
||||||
Reference in New Issue
Block a user