feat(a3): port build pipeline (vm new, build run, artifacts collect)
Implements Phase A3 of plans/implementation-plan-A-B.md:
- commands/vm.py: vm new (replaces New-BuildVM.ps1)
- commands/build.py: build run (replaces Invoke-RemoteBuild.ps1) with WinRM/SSH dispatch and stdout streaming for act_runner
- commands/artifacts.py: artifacts collect (replaces Get-BuildArtifacts.ps1) using transport.fetch()
- 3 PS scripts reduced to shims preserving \0
- Pester tests/{New-BuildVM,Wait-VMReady,Remove-BuildVM}.Tests.ps1 removed; equivalent cases covered in pytest
- Phase C hook: build/artifacts accept opaque VmHandle/vmx; no Windows path assumptions
Tests: 91 pytest, ruff clean, mypy --strict clean, coverage 78.27% (>=70 gate).
Master checklist + step A3 attivita + 'definizione di fatto' updated; A3-closeout.md added.
This commit is contained in:
+30
-195
@@ -1,45 +1,16 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Copies build artifacts from an ephemeral VM to the host artifact directory.
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
.DESCRIPTION
|
||||
Opens a WinRM session to the build VM and uses Copy-Item -FromSession
|
||||
to transfer the artifact archive and optional log files.
|
||||
Validates that at least one file was successfully transferred.
|
||||
Throws on failure so the caller's try/finally can clean up the VM.
|
||||
# Shim: delegates to the Python ci_orchestrator CLI (`artifacts collect`).
|
||||
# Original PS implementation moved to git history; see plans/A3-closeout.md.
|
||||
#
|
||||
# The bound `-Credential` is intentionally discarded — Python uses keyring
|
||||
# via `--credential-target` (defaulting to `BuildVMGuest`).
|
||||
|
||||
.PARAMETER IPAddress
|
||||
IP of the build VM.
|
||||
|
||||
.PARAMETER Credential
|
||||
PSCredential for the guest build user.
|
||||
|
||||
.PARAMETER GuestArtifactPath
|
||||
Full path to the artifact file or directory inside the VM.
|
||||
Example: C:\CI\output\artifacts.zip
|
||||
|
||||
.PARAMETER HostArtifactDir
|
||||
Directory on the host where artifacts will be stored.
|
||||
The directory is created if it does not exist.
|
||||
Example: F:\CI\Artifacts\run-42
|
||||
|
||||
.PARAMETER IncludeLogs
|
||||
If set, also copies *.log files from the guest work directory
|
||||
(C:\CI\build\*.log) alongside the artifact archive.
|
||||
|
||||
.EXAMPLE
|
||||
$cred = Get-Credential
|
||||
.\Get-BuildArtifacts.ps1 `
|
||||
-IPAddress "192.168.11.101" `
|
||||
-Credential $cred `
|
||||
-GuestArtifactPath "C:\CI\output\artifacts.zip" `
|
||||
-HostArtifactDir "F:\CI\Artifacts\run-42"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[Parameter()]
|
||||
@@ -53,175 +24,39 @@ param(
|
||||
|
||||
[switch] $IncludeLogs,
|
||||
|
||||
# Guest OS type — Linux uses SCP instead of WinRM.
|
||||
[ValidateSet('Windows', 'Linux')]
|
||||
[string] $GuestOS = 'Windows',
|
||||
|
||||
# SSH private key path (used when GuestOS=Linux).
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
# SSH username (used when GuestOS=Linux).
|
||||
[string] $SshUser = 'ci_build',
|
||||
|
||||
# Persistent known_hosts file for SSH calls (CI jobs).
|
||||
# Empty string (default) = permissive mode (StrictHostKeyChecking=no, UserKnownHostsFile=NUL).
|
||||
[string] $SshKnownHostsFile = '',
|
||||
|
||||
# Optional job ID and commit SHA written into manifest.json.
|
||||
# When either is non-empty a manifest.json is written to $HostArtifactDir
|
||||
# listing all collected files with name, size, and SHA256.
|
||||
[string] $JobId = '',
|
||||
[string] $Commit = ''
|
||||
[string] $JobId = '',
|
||||
[string] $Commit = '',
|
||||
|
||||
[string] $CredentialTarget = ''
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pyArgs = @(
|
||||
'--ip-address', $IPAddress,
|
||||
'--guest-os', $GuestOS.ToLower(),
|
||||
'--guest-artifact-path', $GuestArtifactPath,
|
||||
'--host-artifact-dir', $HostArtifactDir,
|
||||
'--ssh-user', $SshUser,
|
||||
'--ssh-key-path', $SshKeyPath,
|
||||
'--job-id', $JobId,
|
||||
'--commit', $Commit
|
||||
)
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
||||
if ($SshKnownHostsFile) { $pyArgs += @('--ssh-known-hosts-file', $SshKnownHostsFile) }
|
||||
if ($CredentialTarget) { $pyArgs += @('--credential-target', $CredentialTarget) }
|
||||
if ($IncludeLogs.IsPresent) { $pyArgs += '--include-logs' }
|
||||
|
||||
# ── Helper: write artifact manifest ─────────────────────────────────────────
|
||||
function Write-ArtifactManifest {
|
||||
param([string] $Dir, [string] $JobId, [string] $Commit)
|
||||
$files = @(Get-ChildItem -Path $Dir -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -ne 'manifest.json' })
|
||||
$fileEntries = foreach ($f in $files) {
|
||||
[pscustomobject]@{
|
||||
name = $f.FullName.Substring($Dir.TrimEnd('\', '/').Length).TrimStart('\', '/')
|
||||
size = $f.Length
|
||||
sha256 = (Get-FileHash $f.FullName -Algorithm SHA256).Hash.ToLower()
|
||||
}
|
||||
}
|
||||
$manifest = [ordered]@{
|
||||
ts = (Get-Date -Format 'o')
|
||||
jobId = $JobId
|
||||
commit = $Commit
|
||||
files = @($fileEntries)
|
||||
}
|
||||
$manifestPath = Join-Path $Dir 'manifest.json'
|
||||
try {
|
||||
$manifest | ConvertTo-Json -Depth 4 |
|
||||
Set-Content -Path $manifestPath -Encoding UTF8 -Force -ErrorAction Stop
|
||||
Write-Host "[Get-BuildArtifacts] Manifest written: $manifestPath ($($files.Count) file(s))"
|
||||
} catch {
|
||||
Write-Warning "[Get-BuildArtifacts] Could not write manifest.json: $_"
|
||||
}
|
||||
if ($Credential) {
|
||||
Write-Host '[Get-BuildArtifacts] -Credential ignored; Python CLI uses keyring (set -CredentialTarget to override target name).'
|
||||
}
|
||||
|
||||
if ($GuestOS -eq 'Windows' -and $null -eq $Credential) {
|
||||
throw "Credential is required when GuestOS is Windows."
|
||||
}
|
||||
|
||||
# ── Linux branch: use scp to transfer artifacts ─────────────────────────────────────────────
|
||||
if ($GuestOS -eq 'Linux') {
|
||||
Import-Module (Join-Path $PSScriptRoot '_Transport.psm1') -Force
|
||||
|
||||
if (-not (Test-Path $HostArtifactDir)) {
|
||||
New-Item -ItemType Directory -Path $HostArtifactDir -Force | Out-Null
|
||||
Write-Host "[Get-BuildArtifacts] Created host artifact directory: $HostArtifactDir"
|
||||
}
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Transferring artifacts via SCP from $GuestArtifactPath ..."
|
||||
|
||||
Copy-SshItem `
|
||||
-Source "$GuestArtifactPath/." `
|
||||
-Destination $HostArtifactDir `
|
||||
-IP $IPAddress `
|
||||
-User $SshUser `
|
||||
-KeyPath $SshKeyPath `
|
||||
-KnownHostsFile $SshKnownHostsFile `
|
||||
-Direction 'FromGuest' `
|
||||
-Recurse
|
||||
|
||||
$transferred = @(Get-ChildItem -Path $HostArtifactDir -Recurse -File -ErrorAction SilentlyContinue)
|
||||
if ($transferred.Count -eq 0) {
|
||||
throw "[Get-BuildArtifacts] No files found in $HostArtifactDir after SCP transfer."
|
||||
}
|
||||
Write-Host "[Get-BuildArtifacts] $($transferred.Count) file(s) collected from Linux guest."
|
||||
if ($JobId -ne '' -or $Commit -ne '') {
|
||||
Write-ArtifactManifest -Dir $HostArtifactDir -JobId $JobId -Commit $Commit
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
# Ensure destination exists on host
|
||||
if (-not (Test-Path $HostArtifactDir)) {
|
||||
New-Item -ItemType Directory -Path $HostArtifactDir -Force | Out-Null
|
||||
Write-Host "[Get-BuildArtifacts] Created host artifact directory: $HostArtifactDir"
|
||||
}
|
||||
|
||||
$sessionOptions = New-CISessionOption
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Connecting to VM at $IPAddress..."
|
||||
$session = $null
|
||||
$attempts = 3
|
||||
$delay = 10 # seconds between attempts
|
||||
for ($i = 1; $i -le $attempts; $i++) {
|
||||
try {
|
||||
$session = New-PSSession `
|
||||
-ComputerName $IPAddress `
|
||||
-Credential $Credential `
|
||||
-SessionOption $sessionOptions `
|
||||
-UseSSL `
|
||||
-Port 5986 `
|
||||
-Authentication Basic `
|
||||
-ErrorAction Stop
|
||||
break
|
||||
} catch {
|
||||
if ($i -eq $attempts) { throw }
|
||||
Write-Warning "[Get-BuildArtifacts] WinRM connect attempt $i/$attempts failed — retrying in ${delay}s... ($_)"
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# ── Verify artifact exists in guest ──────────────────────────────────
|
||||
$guestExists = Invoke-Command -Session $session -ScriptBlock {
|
||||
param($path)
|
||||
Test-Path $path
|
||||
} -ArgumentList $GuestArtifactPath
|
||||
|
||||
if (-not $guestExists) {
|
||||
throw "Artifact not found in guest at: $GuestArtifactPath"
|
||||
}
|
||||
|
||||
# ── Copy main artifact ────────────────────────────────────────────────
|
||||
$artifactFileName = Split-Path $GuestArtifactPath -Leaf
|
||||
$hostDestPath = Join-Path $HostArtifactDir $artifactFileName
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Copying $artifactFileName from guest..."
|
||||
Copy-Item -Path $GuestArtifactPath -Destination $hostDestPath -FromSession $session -Force
|
||||
|
||||
# ── Copy logs if requested ────────────────────────────────────────────
|
||||
if ($IncludeLogs) {
|
||||
$logFiles = Invoke-Command -Session $session -ScriptBlock {
|
||||
Get-ChildItem -Path 'C:\CI\build' -Filter '*.log' -Recurse -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty FullName
|
||||
}
|
||||
|
||||
foreach ($log in $logFiles) {
|
||||
$logDest = Join-Path $HostArtifactDir (Split-Path $log -Leaf)
|
||||
Write-Host "[Get-BuildArtifacts] Copying log: $(Split-Path $log -Leaf)"
|
||||
Copy-Item -Path $log -Destination $logDest -FromSession $session -Force
|
||||
}
|
||||
}
|
||||
|
||||
# ── Validate at least the main artifact landed ────────────────────────
|
||||
if (-not (Test-Path $hostDestPath)) {
|
||||
throw "Artifact was not found at expected host path after copy: $hostDestPath"
|
||||
}
|
||||
|
||||
$fileItem = Get-Item $hostDestPath
|
||||
if ($fileItem.Length -eq 0) {
|
||||
throw "Artifact file exists but is empty: $hostDestPath"
|
||||
}
|
||||
$sizeKB = [math]::Round($fileItem.Length / 1KB, 1)
|
||||
|
||||
Write-Host "[Get-BuildArtifacts] Artifact collected: $hostDestPath ($sizeKB KB)"
|
||||
if ($JobId -ne '' -or $Commit -ne '') {
|
||||
Write-ArtifactManifest -Dir $HostArtifactDir -JobId $JobId -Commit $Commit
|
||||
}
|
||||
return $hostDestPath
|
||||
}
|
||||
finally {
|
||||
Remove-PSSession $session -ErrorAction SilentlyContinue
|
||||
}
|
||||
$venvPython = $env:CI_VENV_PYTHON
|
||||
if (-not $venvPython) { $venvPython = 'F:\CI\python\venv\Scripts\python.exe' }
|
||||
& $venvPython -m ci_orchestrator artifacts collect @pyArgs
|
||||
exit $LASTEXITCODE
|
||||
|
||||
+48
-500
@@ -1,543 +1,91 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
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)
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
.DESCRIPTION
|
||||
Called by Invoke-CIJob.ps1 to orchestrate the build inside the VM.
|
||||
# Shim: delegates to the Python ci_orchestrator CLI (`build run`).
|
||||
# Original PS implementation moved to git history; see plans/A3-closeout.md.
|
||||
#
|
||||
# Typed parameters (`[PSCredential]`, `[hashtable]`, `[switch]`) cannot cross
|
||||
# a process boundary as-is; this shim translates them. Credentials are pulled
|
||||
# inside Python via keyring (`--credential-target`), so the bound `-Credential`
|
||||
# is intentionally discarded — callers should set `-CredentialTarget` instead
|
||||
# (or rely on the default `BuildVMGuest` resolved by the Python CLI).
|
||||
|
||||
**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()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidatePattern('^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$')]
|
||||
[string] $IPAddress,
|
||||
|
||||
[Parameter()]
|
||||
[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 = '',
|
||||
|
||||
# [Mode 2, §3.3] Guest-side clone — Git URL for in-VM clone
|
||||
[string] $CloneUrl = '',
|
||||
|
||||
# [Mode 2] Branch to clone (default: main). Ignored if HostSourceDir provided.
|
||||
[string] $CloneBranch = 'main',
|
||||
|
||||
# [Mode 2] Specific commit SHA to checkout. Ignored if HostSourceDir provided.
|
||||
[string] $CloneCommit = '',
|
||||
|
||||
# [Mode 2] Clone with --recurse-submodules. Ignored if HostSourceDir provided.
|
||||
[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] $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 = '',
|
||||
|
||||
# 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] $GuestWorkDir = 'C:\CI\build',
|
||||
|
||||
[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,
|
||||
|
||||
# Skip artifact packaging entirely. Use for jobs that produce no output.
|
||||
# When absent, missing GuestArtifactSource after a successful build is an error.
|
||||
[switch] $SkipArtifact,
|
||||
|
||||
# Guest OS type — Linux uses SSH instead of WinRM.
|
||||
[ValidateSet('Windows', 'Linux')]
|
||||
[string] $GuestOS = 'Windows',
|
||||
|
||||
# SSH private key path (used when GuestOS=Linux).
|
||||
[string] $SshKeyPath = 'F:\CI\keys\ci_linux',
|
||||
|
||||
# SSH username (used when GuestOS=Linux).
|
||||
[string] $SshUser = 'ci_build',
|
||||
|
||||
# Persistent known_hosts file for SSH calls (CI jobs).
|
||||
# Empty string (default) = permissive mode (StrictHostKeyChecking=no, UserKnownHostsFile=NUL).
|
||||
[string] $SshKnownHostsFile = '',
|
||||
|
||||
# Work directory inside the Linux guest.
|
||||
[string] $GuestLinuxWorkDir = '/opt/ci/build',
|
||||
|
||||
# Output directory inside the Linux guest.
|
||||
[string] $GuestLinuxOutputDir = '/opt/ci/output',
|
||||
|
||||
# Extra environment variables injected into the guest before the build command (§6.5).
|
||||
# Keys must be valid env var names. Values are plain strings (never logged).
|
||||
[hashtable] $ExtraGuestEnv = @{}
|
||||
[hashtable] $ExtraGuestEnv = @{},
|
||||
|
||||
# Optional override for the Credential Manager target (overrides any
|
||||
# fallback resolved by Python). Discards $Credential.
|
||||
[string] $CredentialTarget = ''
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pyArgs = @(
|
||||
'--ip-address', $IPAddress,
|
||||
'--guest-os', $GuestOS.ToLower(),
|
||||
'--ssh-user', $SshUser,
|
||||
'--ssh-key-path', $SshKeyPath,
|
||||
'--clone-branch', $CloneBranch,
|
||||
'--gitea-credential-target', $GiteaCredentialTarget,
|
||||
'--configuration', $Configuration,
|
||||
'--guest-work-dir', $GuestWorkDir,
|
||||
'--guest-artifact-zip', $GuestArtifactZip,
|
||||
'--guest-linux-work-dir', $GuestLinuxWorkDir,
|
||||
'--guest-linux-output-dir', $GuestLinuxOutputDir,
|
||||
'--guest-artifact-source', $GuestArtifactSource
|
||||
)
|
||||
|
||||
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 ─────────────────────────────────────
|
||||
$hostCloneMode = -not [string]::IsNullOrWhiteSpace($HostSourceDir)
|
||||
$guestCloneMode = -not [string]::IsNullOrWhiteSpace($CloneUrl)
|
||||
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 ($CloneSubmodules.IsPresent) { $pyArgs += '--clone-submodules' }
|
||||
if ($UseSharedCache.IsPresent) { $pyArgs += '--use-shared-cache' }
|
||||
if ($SkipArtifact.IsPresent) { $pyArgs += '--skip-artifact' }
|
||||
|
||||
if ($GuestOS -eq 'Windows' -and $null -eq $Credential) {
|
||||
throw "Credential is required when GuestOS is Windows."
|
||||
}
|
||||
|
||||
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
|
||||
if ($ExtraGuestEnv -and $ExtraGuestEnv.Count -gt 0) {
|
||||
foreach ($key in $ExtraGuestEnv.Keys) {
|
||||
$pyArgs += @('--extra-env', "$key=$($ExtraGuestEnv[$key])")
|
||||
}
|
||||
}
|
||||
|
||||
$sessionOptions = New-CISessionOption
|
||||
|
||||
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
|
||||
if ($Credential) {
|
||||
Write-Host '[Invoke-RemoteBuild] -Credential ignored; Python CLI uses keyring (set -CredentialTarget to override target name).'
|
||||
}
|
||||
|
||||
$venvPython = $env:CI_VENV_PYTHON
|
||||
if (-not $venvPython) { $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
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a linked clone of the template VM for an ephemeral CI build.
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
.DESCRIPTION
|
||||
Uses vmrun.exe to create a linked clone from a frozen template snapshot.
|
||||
Returns the full path to the new clone's .vmx file on success.
|
||||
The template VM and its snapshot are never modified.
|
||||
# Shim: delegates to the Python ci_orchestrator CLI (`vm new`).
|
||||
# Original PS implementation moved to git history; see plans/A3-closeout.md.
|
||||
|
||||
.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()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -55,49 +22,15 @@ param(
|
||||
[string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pyArgs = @(
|
||||
'--template-path', $TemplatePath,
|
||||
'--snapshot-name', $SnapshotName,
|
||||
'--clone-base-dir', $CloneBaseDir,
|
||||
'--job-id', $JobId,
|
||||
'--vmrun-path', $VmrunPath
|
||||
)
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
||||
|
||||
Resolve-VmrunPath -VmrunPath $VmrunPath | Out-Null
|
||||
|
||||
# --- 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
|
||||
$venvPython = $env:CI_VENV_PYTHON
|
||||
if (-not $venvPython) { $venvPython = 'F:\CI\python\venv\Scripts\python.exe' }
|
||||
& $venvPython -m ci_orchestrator vm new @pyArgs
|
||||
exit $LASTEXITCODE
|
||||
|
||||
Reference in New Issue
Block a user