[CmdletBinding()] param( [string]$Provider = "hyperv", [string[]]$Scenarios = @("self-test", "uac", "hklm-registry", "reboot", "bundled-exec"), [string]$VmName = $(if ($env:COVENANT_VM_NAME) { $env:COVENANT_VM_NAME } else { "covenant-setup-windows" }), [string]$GuestUsername = $(if ($env:COVENANT_WINRM_USERNAME) { $env:COVENANT_WINRM_USERNAME } else { "vagrant" }), [string]$GuestPassword = $(if ($env:COVENANT_WINRM_PASSWORD) { $env:COVENANT_WINRM_PASSWORD } else { "vagrant" }), [switch]$SkipBuild, [switch]$SkipVmBoot, [switch]$SkipViewer, [switch]$HaltAfter, [switch]$DestroyAfter ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # Coverage-harness orchestrator. Drives the Vagrant Windows VM through every # scenario directory under vm\\-install.toml using the # per-scenario guest scripts under scripts\windows-vm\coverage\.ps1. # # All install/uninstall side effects happen INSIDE the VM. The host only: # 1. Builds covenant-setup.exe (release). # 2. Stages payload trees per scenario (where the manifest references # payload\covenant-setup.exe). # 3. Boots the VM, uploads the exe + scenarios + guest scripts. # 4. WinRM-invokes each guest script and aggregates results. # # Existing scripts under scripts\windows-vm\coverage\*.ps1 are guest-side and # already accept -Exe -Manifest -WorkRoot. function Assert-Command { param([Parameter(Mandatory)][string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Required command not found on PATH: $Name" } } function Invoke-Tool { param( [Parameter(Mandatory)][string]$FilePath, [string[]]$Arguments = @() ) Write-Host "==> $FilePath $($Arguments -join ' ')" Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue & $FilePath @Arguments $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue $exitCode = if ($exitCodeVar) { [int]$exitCodeVar.Value } elseif ($?) { 0 } else { 1 } if ($exitCode -ne 0) { throw "Command failed with exit code ${exitCode}: $FilePath $($Arguments -join ' ')" } } function Invoke-Vagrant { param([string[]]$Arguments) Invoke-Tool -FilePath "vagrant" -Arguments $Arguments } function Invoke-VagrantOutput { param([string[]]$Arguments) Write-Host "==> vagrant $($Arguments -join ' ')" Remove-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue $output = & vagrant @Arguments 2>&1 $exitCodeVar = Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue $exitCode = if ($exitCodeVar) { [int]$exitCodeVar.Value } elseif ($?) { 0 } else { 1 } return [pscustomobject]@{ Output = ($output | Out-String); ExitCode = $exitCode } } function Open-HyperVViewer { param([Parameter(Mandatory)][string]$VmName) $vmConnect = Join-Path $env:SystemRoot "System32\vmconnect.exe" if (-not (Test-Path -LiteralPath $vmConnect)) { return } Start-Process -FilePath $vmConnect -ArgumentList @("localhost", $VmName) | Out-Null } # The engine enforces the -install.toml naming convention, so each # scenario directory carries its own manifest file name; discover it instead # of hardcoding install.toml. function Get-ScenarioManifest { param([Parameter(Mandatory)][string]$ScenarioDir) $candidates = @(Get-ChildItem -LiteralPath $ScenarioDir -Filter "*install.toml" -File | Sort-Object Name) if ($candidates.Count -eq 0) { throw "Scenario manifest (*install.toml) not found in: $ScenarioDir" } if ($candidates.Count -gt 1) { throw "Multiple *install.toml manifests found in: $ScenarioDir" } return $candidates[0] } $repoRoot = Split-Path -Parent $PSScriptRoot $releaseExe = Join-Path $repoRoot "target\release\covenant-setup.exe" $outputRoot = Join-Path $repoRoot "dist\vagrant-coverage" $summaryPath = Join-Path $outputRoot "summary.json" $guestRoot = "C:\Users\vagrant\AppData\Local\Temp\covenant-setup-coverage" $guestExe = Join-Path $guestRoot "bin\covenant-setup.exe" $guestScriptRoot = Join-Path $guestRoot "scripts" $guestScenarioRoot = Join-Path $guestRoot "scenarios" $guestWorkRoot = Join-Path $guestRoot "work" Assert-Command -Name "cargo" Assert-Command -Name "vagrant" New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null if (-not $SkipBuild) { Invoke-Tool -FilePath "cargo" -Arguments @("build", "--release") } if (-not (Test-Path -LiteralPath $releaseExe)) { throw "Release binary not found at $releaseExe" } # Stage the payload tree for each scenario manifest that references # payload\covenant-setup.exe (relative to the manifest dir). foreach ($scenario in $Scenarios) { $manifest = (Get-ScenarioManifest -ScenarioDir (Join-Path $repoRoot "vm\$scenario")).FullName $payloadDir = Join-Path $repoRoot "vm\$scenario\payload" $manifestText = Get-Content -LiteralPath $manifest -Raw if ($manifestText -match 'payload\\\\covenant-setup\.exe' -or $manifestText -match 'payload[\\/]covenant-setup\.exe') { New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null Copy-Item -LiteralPath $releaseExe -Destination (Join-Path $payloadDir "covenant-setup.exe") -Force } } $results = @() $hadFailure = $false try { if (-not $SkipVmBoot) { Invoke-Vagrant -Arguments @("up", "--provider", $Provider) } if ($Provider -ieq "hyperv" -and -not $SkipViewer) { Open-HyperVViewer -VmName $VmName } $waitForShellCommand = @( "for (`$i = 0; `$i -lt 90; `$i++) { if (Get-Process -Name explorer -ErrorAction SilentlyContinue) { exit 0 }; Start-Sleep -Seconds 2 }" "`$winlogon = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon'" "Write-Output ('consoleUser=' + (Get-CimInstance Win32_ComputerSystem).UserName)" "Write-Output ('computerName=' + `$env:COMPUTERNAME)" "foreach (`$name in 'AutoAdminLogon','ForceAutoLogon','DefaultUserName','DefaultDomainName','AutoLogonCount') { `$value = `$winlogon.PSObject.Properties[`$name]; Write-Output (`$name + '=' + `$(if (`$value) { `$value.Value } else { '' })) }" "Write-Output ('DefaultPassword=' + `$(if (`$winlogon.PSObject.Properties['DefaultPassword']) { '' } else { '' }))" "Write-Error 'Explorer shell did not start in time. Auto-logon state is printed above.'" "exit 1" ) -join "; " Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $waitForShellCommand) # Prepare guest layout. $prepCommand = @( "New-Item -ItemType Directory -Force -Path '$guestRoot' | Out-Null" "New-Item -ItemType Directory -Force -Path '$(Join-Path $guestRoot 'bin')' | Out-Null" "New-Item -ItemType Directory -Force -Path '$guestScriptRoot' | Out-Null" "New-Item -ItemType Directory -Force -Path '$guestScenarioRoot' | Out-Null" "New-Item -ItemType Directory -Force -Path '$guestWorkRoot' | Out-Null" ) -join "; " Invoke-Vagrant -Arguments @("winrm", "-s", "powershell", "-c", $prepCommand) # Upload covenant-setup.exe and per-scenario assets. Invoke-Vagrant -Arguments @("upload", $releaseExe, $guestExe) foreach ($scenario in $Scenarios) { $localScenarioDir = Join-Path $repoRoot "vm\$scenario" $remoteScenarioDir = Join-Path $guestScenarioRoot $scenario Invoke-Vagrant -Arguments @("upload", $localScenarioDir, $remoteScenarioDir) $localScript = Join-Path $repoRoot "scripts\windows-vm\coverage\$scenario.ps1" if (-not (Test-Path -LiteralPath $localScript)) { throw "Scenario script not found: $localScript" } $remoteScript = Join-Path $guestScriptRoot "$scenario.ps1" Invoke-Vagrant -Arguments @("upload", $localScript, $remoteScript) } # Run each scenario in the guest. Capture exit code and stderr/stdout # without throwing so we can record per-scenario status. foreach ($scenario in $Scenarios) { Write-Host "" Write-Host "[coverage] -> $scenario" -ForegroundColor Cyan $remoteScript = Join-Path $guestScriptRoot "$scenario.ps1" $manifestName = (Get-ScenarioManifest -ScenarioDir (Join-Path $repoRoot "vm\$scenario")).Name $remoteManifest = Join-Path $guestScenarioRoot "$scenario\$manifestName" $remoteWork = Join-Path $guestWorkRoot $scenario $logRel = "$scenario\guest.log" $remoteLog = Join-Path $guestWorkRoot $logRel $invokeCommand = @( "New-Item -ItemType Directory -Force -Path '$remoteWork' | Out-Null" "& '$remoteScript' -Exe '$guestExe' -Manifest '$remoteManifest' -WorkRoot '$remoteWork' *> '$remoteLog'" "exit `$LASTEXITCODE" ) -join "; " $invocation = Invoke-VagrantOutput -Arguments @("winrm", "-s", "powershell", "-c", $invokeCommand) $localScenarioOut = Join-Path $outputRoot $scenario New-Item -ItemType Directory -Force -Path $localScenarioOut | Out-Null # Pull the guest log. $logFetch = Invoke-VagrantOutput -Arguments @("winrm", "-s", "powershell", "-c", "if (Test-Path -LiteralPath '$remoteLog') { Get-Content -LiteralPath '$remoteLog' -Raw } else { '__COVENANT_NO_LOG__' }") Set-Content -LiteralPath (Join-Path $localScenarioOut "guest.log") -Value $logFetch.Output -Encoding UTF8 $success = ($invocation.ExitCode -eq 0) $results += [pscustomobject]@{ scenario = $scenario; success = $success; exitCode = $invocation.ExitCode } if (-not $success) { $hadFailure = $true Write-Host "[coverage] $scenario FAILED (exit $($invocation.ExitCode))" -ForegroundColor Red Write-Host $invocation.Output } else { Write-Host "[coverage] $scenario OK" -ForegroundColor Green } } } finally { $summary = [pscustomobject]@{ scenarios = $results success = -not $hadFailure } $summary | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $summaryPath -Encoding UTF8 Write-Host "" Write-Host "Summary: $summaryPath" foreach ($r in $results) { $color = if ($r.success) { "Green" } else { "Red" } Write-Host (" {0,-18} success={1} exit={2}" -f $r.scenario, $r.success, $r.exitCode) -ForegroundColor $color } if ($DestroyAfter) { try { Invoke-Vagrant -Arguments @("destroy", "-f") } catch { Write-Warning $_.Exception.Message } } elseif ($HaltAfter) { try { Invoke-Vagrant -Arguments @("halt") } catch { Write-Warning $_.Exception.Message } } } if ($hadFailure) { throw "One or more coverage scenarios failed. See $summaryPath." }