Private/Invoke-Win32ToolkitHyperVRun.ps1
|
function Invoke-Win32ToolkitHyperVRun { <# .SYNOPSIS Runs a test/capture against the Hyper-V backend: reset -> copy in -> run phases -> copy out. .DESCRIPTION The Hyper-V counterpart of launching a .wsb. It reverts the VM to its warm checkpoint, opens a PowerShell Direct session, copies the project to C:\PSADT (and an optional baseline to C:\PSADTOld), runs each phase synchronously with the guest's Windows PowerShell 5.1, copies the requested outputs back UNDER the host project (so the existing host-side waiters/consumers work unchanged), and always closes the session + reverts the VM. Returns $true on completion. This is the 'HyperV' branch of the test-backend seam; the flows call it instead of building a .wsb. See knowledge-base/designs/hyperv-backend-plan.md. .PARAMETER ProjectPath The PSADT project under test (host path). Copied to C:\PSADT in the guest. .PARAMETER Phase Ordered phases to run, each an object with: Label, Command (a 5.1-safe guest command string), and optional IgnoreExit ([bool], default $false). .PARAMETER Output Project-relative globs to copy back from the guest after the phases (default the capture JSON + Sandbox\Logs). .PARAMETER BaselineProjectPath Update baseline-project mode: a second project copied read-only-in-effect to C:\PSADTOld. .PARAMETER VMName / Credential / CheckpointName Resolved from config / the stored guest credential when omitted. .OUTPUTS [bool] — $true if the run completed (individual phase failures are warned, not fatal, matching the Sandbox fire-and-forget semantics; callers judge results from the copied-back outputs). #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$ProjectPath, [Parameter(Mandatory)] [object[]]$Phase, [string[]]$Output = @('Documentation\InstallationChanges_*.json', 'Documentation\Targeted_Documentation_Log_*.txt', 'Sandbox\Logs\*'), [string]$BaselineProjectPath, [string]$VMName, [pscredential]$Credential, [string]$CheckpointName = 'clean-base' ) if (-not $VMName) { $VMName = Get-Win32ToolkitConfigValue -Name 'HyperVVMName' -Default 'win32tk-golden' } if (-not $CheckpointName) { $CheckpointName = Get-Win32ToolkitConfigValue -Name 'HyperVCheckpoint' -Default 'clean-base' } if (-not $Credential) { $Credential = Get-Win32ToolkitGuestCredential } if (-not $Credential) { throw "No Hyper-V guest credential is configured. Run New-Win32ToolkitTestVM first (it stores the credential), or pass -Credential." } $session = $null # Suppress HOST progress bars for the whole run. Copy-Item -To/-FromSession (the project copy in/out) # and the Hyper-V checkpoint cmdlets paint out-of-band, absolute-cursor progress regions that tear the # Spectre.Console TUI this runs under. The copy/session helpers below don't set their own # $ProgressPreference, so they inherit this via dynamic scoping — no per-helper edits needed. Restored # in finally so the intentional Azure-upload bar on the (later) publish path is unaffected. $prevProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' # ── Deps checkpoint (opt-in: HyperVDepsCheckpoint=On) ───────────────────────────────────────────── # When the project stages dependencies, a 'clean-base+deps-<key>' checkpoint lets every later run # skip the per-run dependency install entirely: restore THAT instead of clean-base and drop the # tagged dep phase. The key hashes the staged set + every payload byte + the parent checkpoint's # identity, so any change simply never matches and falls back to clean-base + a live install (which # then re-creates the checkpoint). VM maintenance deletes all checkpoints — the fallback is routine. $effectiveCheckpoint = $CheckpointName $skipDepPhases = $false $createDepsCheckpoint = $false $depsCpName = $null $depsFeatureOn = $false try { $depsFeatureOn = ((Get-Win32ToolkitConfigValue -Name 'HyperVDepsCheckpoint' -Default 'Off') -eq 'On') } catch { $depsFeatureOn = $false } if ($depsFeatureOn) { $depsCpName = Get-Win32ToolkitDepsCheckpointName -ProjectPath $ProjectPath -VMName $VMName -ParentCheckpointName $CheckpointName if ($depsCpName) { if (Get-VMCheckpoint -VMName $VMName -Name $depsCpName -ErrorAction SilentlyContinue) { $effectiveCheckpoint = $depsCpName $skipDepPhases = $true Write-Host "✓ Using checkpoint '$depsCpName' — this project's dependencies are pre-installed in the image." -ForegroundColor Green } elseif (-not $BaselineProjectPath) { $createDepsCheckpoint = $true } else { # NEVER create the checkpoint during a baseline (Update) run: the read-only C:\PSADTOld # copy-in happens BEFORE the dep phase, so the frozen image would contain an # icacls-locked folder that later baseline copy-ins can neither delete nor overwrite — # poisoning every subsequent Update run of the project (the key hashes deps, not the # baseline, so it would never rotate). USING an existing checkpoint is fine: it was # frozen without a baseline, and this run's C:\PSADTOld is erased by the teardown revert. Write-Verbose 'Deps checkpoint not created during a baseline run (it would freeze the read-only C:\PSADTOld into the image); an InstallUninstall/capture run will create it.' } } } # ── Overlap: build the copy-in zip(s) WHILE the checkpoint reverts ──────────────────────────────── # The zip needs no session and used to sit serially in every run (5-20 s). Pure .NET work in a # thread job; joined (with errors surfaced) right before the transfer. No ThreadJob => build inline # in the copy helper, exactly as before. $zipJobs = @{} $canThreadJob = [bool](Get-Command Start-ThreadJob -ErrorAction SilentlyContinue) if ($canThreadJob) { $zipBuilder = { param($src, $dst) Add-Type -AssemblyName 'System.IO.Compression.FileSystem' -ErrorAction SilentlyContinue [System.IO.Compression.ZipFile]::CreateFromDirectory($src, $dst, [System.IO.Compression.CompressionLevel]::NoCompression, $false) $dst } $projZipPath = Join-Path ([System.IO.Path]::GetTempPath()) ('w32proj_' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.zip') $zipJobs['project'] = @{ Job = (Start-ThreadJob -ScriptBlock $zipBuilder -ArgumentList $ProjectPath, $projZipPath); Path = $projZipPath } if ($BaselineProjectPath) { $baseZipPath = Join-Path ([System.IO.Path]::GetTempPath()) ('w32proj_' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.zip') $zipJobs['baseline'] = @{ Job = (Start-ThreadJob -ScriptBlock $zipBuilder -ArgumentList $BaselineProjectPath, $baseZipPath); Path = $baseZipPath } } } # Joins a prebuild job and returns its zip path — or $null (build-inline fallback) on any job error. $joinZip = { param($key) if (-not $zipJobs.ContainsKey($key)) { return $null } try { $null = Receive-Job -Job $zipJobs[$key].Job -Wait -ErrorAction Stop return $zipJobs[$key].Path } catch { Write-Verbose "Zip prebuild '$key' failed ($($_.Exception.Message)) — building inline." Remove-Item -LiteralPath $zipJobs[$key].Path -Force -ErrorAction SilentlyContinue return $null } } try { # Interactive phases need a logged-in desktop, so ask the session to ensure/recover one. $needsDesktop = @($Phase | Where-Object { $_.Interactive }).Count -gt 0 Write-Verbose "Reverting '$VMName' to '$effectiveCheckpoint' and connecting over PowerShell Direct..." $session = New-Win32ToolkitHyperVSession -VMName $VMName -Credential $Credential -CheckpointName $effectiveCheckpoint -EnsureDesktop:$needsDesktop Write-Verbose "Copying project into the guest (C:\PSADT)..." Copy-Win32ToolkitProjectToGuest -Session $session -ProjectPath $ProjectPath -GuestPath 'C:\PSADT' -PrebuiltZip (& $joinZip 'project') if ($BaselineProjectPath) { # -ReadOnly reproduces the Sandbox <ReadOnly>true</ReadOnly> mount, so the baseline's own PSADT # run can't write into C:\PSADTOld on Hyper-V while the same run fails under Sandbox. Write-Verbose 'Copying the update baseline into the guest (C:\PSADTOld, read-only)...' Copy-Win32ToolkitProjectToGuest -Session $session -ProjectPath $BaselineProjectPath -GuestPath 'C:\PSADTOld' -ReadOnly -PrebuiltZip (& $joinZip 'baseline') } # Deps are baked into the restored image: remove the in-guest installer script so the CAPTURE # script (which installs deps in-script, not as a tagged phase) skips them too. if ($skipDepPhases) { Invoke-Command -Session $session -ScriptBlock { param($p) Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue } -ArgumentList 'C:\PSADT\Sandbox\InstallDependencies.ps1' -ErrorAction SilentlyContinue } # If any phase is interactive, open the VM console so the operator can watch the PSADT GUI. if ($needsDesktop) { Write-Verbose 'Opening the VM console (vmconnect) for interactive GUI testing...' Start-Process -FilePath 'vmconnect.exe' -ArgumentList 'localhost', $VMName -ErrorAction SilentlyContinue } foreach ($ph in $Phase) { if ($ph.Pause) { Read-Host " $($ph.Label) — press Enter to continue" continue } if ($ph.DepPhase -and $skipDepPhases) { Write-Verbose " Skipping '$($ph.Label)' — dependencies are pre-installed in the checkpoint." continue } # Every phase runs as SYSTEM (the Intune context). For an interactive phase PSADT's own deploy # mode renders its GUI in the logged-on user's session (a real desktop is ensured above). $exit = Invoke-Win32ToolkitGuestPhase -Session $session -Command $ph.Command -Label $ph.Label if (-not $ph.IgnoreExit -and $exit -ne 0) { Write-Warning "Guest phase '$($ph.Label)' exited with code $exit." } # A SUCCESSFUL dep install is the moment to freeze the deps checkpoint (opt-in feature): the # image now equals clean-base + this exact dependency set. Only one clean-base+deps-* is kept. # A failure here is non-fatal — the run continues exactly as without the feature. if ($ph.DepPhase -and $createDepsCheckpoint -and $exit -eq 0) { try { Get-VMCheckpoint -VMName $VMName -ErrorAction SilentlyContinue | Where-Object { $_.Name -like 'clean-base+deps-*' } | Remove-VMCheckpoint -ErrorAction SilentlyContinue Set-VM -Name $VMName -CheckpointType Standard -ErrorAction Stop Checkpoint-VM -VMName $VMName -SnapshotName $depsCpName -ErrorAction Stop $effectiveCheckpoint = $depsCpName $createDepsCheckpoint = $false Write-Host "✓ Deps checkpoint '$depsCpName' created — later runs of this project skip the dependency install." -ForegroundColor Green } catch { Write-Warning "Could not create the deps checkpoint (run continues without it): $($_.Exception.Message)" } } } Write-Verbose "Copying results back to the project..." $guestGlobs = $Output | ForEach-Object { Join-Path 'C:\PSADT' $_ } Copy-Win32ToolkitResultsFromGuest -Session $session -GuestPath $guestGlobs -Destination $ProjectPath -GuestRoot 'C:\PSADT' return $true } catch { Write-Warning "Hyper-V run failed: $($_.Exception.Message)" return $false } finally { # Revert to the EFFECTIVE checkpoint: with the deps feature that leaves the VM clean-with-deps # (still never holding app/test state), and the R2 marker then matches the next run of the same # project for the single-revert skip. Remove-Win32ToolkitHyperVSession -Session $session -VMName $VMName -CheckpointName $effectiveCheckpoint -Revert # INTERACTIVE runs leave a vmconnect window attached to the reverted-but-running guest — the # operator can keep poking it after the teardown, invisibly dirtying the VM within the marker's # TTL. Never presume clean after an interactive run; unattended runs (no console, no operator) # keep the single-revert skip. if ($needsDesktop) { $script:HyperVCleanMarker = $null } # Any unconsumed prebuild zips (job failed, run threw before the join) are cleaned here. foreach ($k in $zipJobs.Keys) { if ($zipJobs[$k].Job) { Remove-Job -Job $zipJobs[$k].Job -Force -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $zipJobs[$k].Path -Force -ErrorAction SilentlyContinue } # Restore the caller's preference (the teardown Restore-VMCheckpoint above stays silenced), so this # never leaks to a later Publish/Azure-upload bar. Runs on both the return and the catch paths. $ProgressPreference = $prevProgress } } |