73fca1c74a
Git Credential Manager (GCM) installed by Git for Windows tries to open an interactive credential prompt or wincredman store — both fail in a WinRM session (no TTY, no interactive desktop). Fix: - Set GIT_TERMINAL_PROMPT=0 to suppress all interactive git prompts - Prepend '-c credential.helper=' (empty string) to reset the helper chain before injecting our store-based helper (clearing GCM from the chain) - Custom credential helper (store --file <tmp>) then appended only when PAT present Without PAT (public repo): GCM still cleared, no prompt attempted, clean fail if auth is actually required. With PAT: store-based helper used exclusively, GCM never invoked. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
391 lines
17 KiB
PowerShell
391 lines
17 KiB
PowerShell
#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)
|
|
|
|
.DESCRIPTION
|
|
Called by Invoke-CIJob.ps1 to orchestrate the build inside the VM.
|
|
|
|
**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(Mandatory)]
|
|
[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
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
Import-Module (Join-Path $PSScriptRoot '_Common.psm1') -Force
|
|
|
|
# ── 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."
|
|
}
|
|
|
|
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
|
|
|
|
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'
|
|
|
|
# Clear any system credential helper (e.g. GCM) that may try to open UI/TTY.
|
|
# '-c credential.helper=' (empty) resets the helper chain before we inject ours.
|
|
$credHelperArgs = @('-c', 'credential.helper=')
|
|
|
|
# Credentials via temp .git-credentials file (§1.5: token never in URL, argv, or logs)
|
|
$tmpCreds = $null
|
|
if ($patUser -and $patPass) {
|
|
$uri = [uri]$cloneUrl
|
|
$credLine = "$($uri.Scheme)://${patUser}:${patPass}@$($uri.Host)"
|
|
$tmpCreds = "C:\CI\tmp_gc_$([guid]::NewGuid().ToString('N'))"
|
|
[System.IO.File]::WriteAllText($tmpCreds, $credLine)
|
|
$credHelperArgs += @('-c', "credential.helper=store --file $tmpCreds")
|
|
}
|
|
|
|
$gitArgs = $credHelperArgs + $gitArgs
|
|
|
|
try {
|
|
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)"
|
|
}
|
|
}
|
|
} finally {
|
|
# Always delete credentials file (§1.5: cleanup)
|
|
if ($tmpCreds -and (Test-Path $tmpCreds)) {
|
|
Remove-Item $tmpCreds -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
} -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)
|
|
# 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'
|
|
}
|
|
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) {
|
|
$archiveDir = Split-Path $artifactZip -Parent
|
|
if (-not (Test-Path $archiveDir)) {
|
|
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
|
|
}
|
|
$srcPath = Join-Path $workDir $artifactSrc
|
|
$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
|
|
|
|
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
|
|
}
|
|
$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)"
|
|
}
|
|
}
|
|
|
|
Write-Host "[Invoke-RemoteBuild] Build succeeded. Artifact at $GuestArtifactZip"
|
|
}
|
|
finally {
|
|
Remove-PSSession $session -ErrorAction SilentlyContinue
|
|
}
|