#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()] [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 '' -Password '' -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, # When set, skip artifact packaging and do NOT write artifacts.zip in the guest. # Use for burn-in / smoke builds that intentionally produce no output. # In production (default: absent) the build MUST produce GuestArtifactSource or the # job fails — a silently-empty build is treated as 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). # Pass an empty string to disable host-key checking (template provisioning only). [string] $SshKnownHostsFile = 'F:\CI\State\known_hosts', # 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 = @{} ) 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." } 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 } } $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 use -SkipArtifact for builds that intentionally produce no files." } 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 }